测试食谱
Test recipes
测试配方是一个测试计划,概述了应在哪个级别测试特定功能。
A test recipe is a test plan, outlining at which level a particular feature should be tested.
这本书很特别。这些章节相互借鉴,积累了惊人的深度。准备好享受美食吧。
This book is something special. The chapters build on each other to a startling accumulation of depth. Get ready for a treat.
——来自 Robert C. Martin 第二版的前言,cleancoder.com
—From the foreword of the second edition by Robert C. Martin, cleancoder.com
从现在该领域的经典中学习单元测试的最佳方法。
The best way to learn unit testing from what is now a classic in the field.
—Raphael Faria,LG 电子公司
—Raphael Faria, LG Electronics
教您有效单元测试的理念以及具体细节。
Teaches you the philosophy as well as the nuts and bolts for effective unit testing.
—Pradeep Chellappan,微软
—Pradeep Chellappan, Microsoft
当我的团队成员问我如何以正确的方式编写单元测试时,我简单地回答:买这本书!
When my team members ask me how to write unit tests the right way, I simply answer: Get this book!
—Alessandro Campeis,Vimar SpA
—Alessandro Campeis, Vimar SpA
有关单元测试的最佳资源。
The single best resource on unit testing.
—Kaleb Pederson,Next IT 公司
—Kaleb Pederson, Next IT Corporation
我读过的最有用和最新的单元测试指南。
The most useful and up-to-date guide to unit testing I have ever read.
——弗朗西斯科·戈吉,菲亚特
—Francesco Goggi, FIAT
对于任何希望学习或完善其单元测试知识的认真的 .NET 开发人员来说,这是必须的。
A must for any serious .NET developer wishing to learn or perfect their unit testing knowledge.
—Karl Metivier,加鼎证券金融公司
—Karl Metivier, Desjardins Security Financial
有关这些及其他曼宁书籍的在线信息和订购,请访问www.manning.com。出版商在订购这些书籍时提供折扣。
For online information and ordering of these and other Manning books, please visit www.manning.com. The publisher offers discounts on these books when ordered in quantity.
获取更多资讯,请联系
For more information, please contact
特约销售部
Special Sales Department
曼宁出版公司
Manning Publications Co.
鲍德温路20号
20 Baldwin Road
邮政信箱 761
PO Box 761
谢尔特岛, 纽约 11964
Shelter Island, NY 11964
电子邮件:orders@manning.com
Email: orders@manning.com
©2024 Manning Publications Co. 保留所有权利。
©2024 by Manning Publications Co. All rights reserved.
未经出版商事先书面许可,不得以任何形式或通过电子、机械、影印或其他方式复制、存储或传播本出版物的任何部分。
No part of this publication may be reproduced, stored in a retrieval system, or transmitted, in any form or by means electronic, mechanical, photocopying, or otherwise, without prior written permission of the publisher.
制造商和销售商用来区分其产品的许多名称都被称为商标。如果这些名称出现在书中,并且曼宁出版公司知道商标声明,则这些名称均以首字母大写或全部大写印刷。
Many of the designations used by manufacturers and sellers to distinguish their products are claimed as trademarks. Where those designations appear in the book, and Manning Publications was aware of a trademark claim, the designations have been printed in initial caps or all caps.
♾认识到保存所写内容的重要性,曼宁的政策是使用无酸纸印刷我们出版的书籍,并且我们为此尽了最大努力。曼宁还认识到我们有责任保护地球资源,因此印刷的纸张至少有 15% 是回收的,并且在加工过程中不使用元素氯。
♾ Recognizing the importance of preserving what has been written, it is Manning’s policy to have the books we publish printed on acid-free paper, and we exert our best efforts to that end. Recognizing also our responsibility to conserve the resources of our planet, Manning books are printed on paper that is at least 15 percent recycled and processed without the use of elemental chlorine.
|
|
曼宁出版公司 Manning Publications Co. 20 鲍德温路技术 20 Baldwin Road Technical 邮政信箱 761 PO Box 761 谢尔特岛, 纽约 11964 Shelter Island, NY 11964 |
|
开发编辑: Development editor: |
康纳·奥布莱恩 Connor O’Brien |
|
技术开发编辑: Technical development editor: |
迈克·谢泼德 Mike Shepard |
|
审稿编辑: Review editors: |
阿德里亚娜·萨博和邓贾·尼基托维奇 Adriana Sabo and Dunja Nikitović |
|
制作编辑: Production editor: |
凯西·罗斯兰 Kathy Rossland |
|
复制编辑器: Copy editor: |
安迪·卡罗尔 Andy Carroll |
|
校对: Proofreader: |
凯蒂·田纳特 Katie Tennant |
|
技术校对员: Technical proofreader: |
让-弗朗索瓦·莫兰 Jean-François Morin |
|
排字机: Typesetter: |
丹尼斯·达林尼克 Dennis Dalinnik |
|
封面设计师: Cover designer: |
玛丽亚·都铎 Marija Tudor |
国际书号:9781617297489
ISBN: 9781617297489
致塔尔、伊塔玛、阿维夫和伊多。我的家人。
To Tal, Itamar, Aviv, and Ido. My family.
——罗伊·奥谢罗夫
—Roy Osherove
致我的妻子尼娜和儿子蒂莫西。
To my wife Nina and son Timothy.
——弗拉基米尔·霍里科夫
—Vladimir Khorikov
foreword to the second edition
1.2 Defining unit testing, step by step
1.3 Entry points and exit points
1.5 Different exit points, different techniques
1.7 Characteristics of a good unit test
TDD: Not a substitute for good unit tests
Three core skills needed for successful TDD
2.2 The library, the assert, the runner, and the reporter
2.3 What unit testing frameworks offer
xUnit, TAP, and Jest structures
2.4 Introducing the Password Verifier project
2.5 verifyPassword 的第一个 Jest 测试
2.5 The first Jest test for verifyPassword
The Arrange-Act-Assert pattern
String comparisons and maintainability
Refactoring the production code
2.6 Trying the beforeEach() route
beforeEach() and scroll fatigue
2.7 Trying the factory method route
Replacing beforeEach() completely with factory methods
2.8 Going full circle to test()
2.9 Refactoring to parameterized tests
2.10 Checking for expected thrown errors
3 Breaking dependencies with stubs
3.3 Generally accepted design approaches to stubbing
Stubbing out time with parameter injection
Dependencies, injections, and control
3.4 Functional injection techniques
Dependency injection via partial application
3.5 Modular injection techniques
3.6 Moving toward objects with constructor functions
3.7 Object-oriented injection techniques
Injecting an object instead of a function
4 Interaction testing using mock objects
4.1 Interaction testing, mocks, and stubs
4.3 Standard style: Introduce parameter refactoring
4.4 The importance of differentiating between mocks and stubs
Refactoring the production code in a modular injection style
A test example with modular-style injection
4.6 Mocks in a functional style
Working with higher-order functions and not currying
4.7 Mocks in an object-oriented style
Refactoring production code for injection
Refactoring production code with interface injection
4.8 Dealing with complicated interfaces
Example of a complicated interface
Writing tests with complicated interfaces
Downsides of using complicated interfaces directly
The interface segregation principle
A functional example of a partial mock
An object-oriented partial mock example
5.1 Defining isolation frameworks
Choosing a flavor: Loose vs. typed
5.2 Faking modules dynamically
Some things to notice about Jest’s API
Consider abstracting away direct dependencies
5.3 Functional dynamic mocks and stubs
5.4 Object-oriented dynamic mocks and stubs
Using a loosely typed framework
Switching to a type-friendly framework
5.5 Stubbing behavior dynamically
An object-oriented example with a mock and a stub
Stubs and mocks with substitute.js
5.6 Advantages and traps of isolation frameworks
You don’t need mock objects most of the time
Having more than one mock per test
6 Unit testing asynchronous code
6.1 Dealing with async data fetching
An initial attempt with an integration test
Integration testing of async/await
Challenges with integration tests
6.2 Making our code unit-test friendly
Stubbing timers out with monkey-patching
6.4 Dealing with common events
6.5 Bringing in the DOM testing library
7.1 How to know you trust a test
A real bug has been uncovered in the production code
A buggy test gives a false failure
The test is out of date due to a change in functionality
The test conflicts with another test
7.3 Avoiding logic in unit tests
Logic in asserts: Creating dynamic expected values
7.4 Smelling a false sense of trust in passing tests
Tests that don’t assert anything
Mixing unit tests and flaky integration tests
What can you do once you’ve found a flaky test?
Preventing flakiness in higher-level tests
8.1 Changes forced by failing tests
The test is not relevant or conflicts with another test
Changes in the production code’s API
8.2 Refactoring to increase maintainability
Avoid testing private or protected methods
Use parameterized tests to remove duplication
Internal behavior overspecification with mocks
Exact outputs and ordering overspecification
9.2 Magic values and naming variables
9.3 Separating asserts from actions
9.4 Setting up and tearing down
10 Developing a testing strategy
10.1 Common test types and levels
Unit tests and component tests
The end-to-end-only antipattern
The low-level-only test antipattern
Disconnected low-level and high-level tests
10.3 Test recipes as a strategy
When do I write and use a test recipe?
10.4 Managing delivery pipelines
Delivery vs. discovery pipelines
11 Integrating unit testing into the organization
11.1 Steps to becoming an agent of change
Be prepared for the tough questions
Convince insiders: Champions and blockers
Identify possible starting points
Guerrilla implementation (bottom-up)
Convincing management (top-down)
Aim for specific goals, metrics, and KPIs
Realize that there will be hurdles
Ad hoc implementations and first impressions
11.5 Tough questions and answers
How much time will unit testing add to the current process?
Will my QA job be at risk because of unit testing?
Is there proof that unit testing helps?
Why is the QA department still finding bugs?
We have lots of code without tests: Where do we start?
What if we develop a combination of software and hardware?
How can we know we don’t have bugs in our tests?
Why do I need tests if my debugger shows that my code works?
12.1 Where do you start adding tests?
12.2 Choosing a selection strategy
Pros and cons of the easy-first strategy
Pros and cons of the hard-first strategy
12.3 Writing integration tests before refactoring
Read Michael Feathers’ book on legacy code
Use CodeScene to investigate your production code
那一年一定是 2009 年。我在奥斯陆举行的挪威开发者大会上发表讲话。(啊,六月的奥斯陆!) 活动在一个巨大的体育场举行。会议组织者将看台分成几部分,在看台前搭建舞台,并用厚厚的黑布覆盖,以创建八个不同的会议“房间”。我记得我的演讲刚刚结束,内容是关于 TDD、SOLID、天文学或其他什么的,突然,我旁边的舞台上传来响亮而沙哑的歌声和吉他演奏。
The year must have been 2009. I was speaking at the Norwegian Developers Conference in Oslo. (Ah, Oslo in June!) The event was held in a huge sports arena. The conference organizers divided the bleachers into sections, built stages in front of them, and draped them with thick black cloth in order to create eight different session “rooms.” I remember I was just about finished with my talk, which was about TDD, or SOLID, or astronomy, or something, when suddenly, from the stage next to me, came this loud and raucous singing and guitar playing.
窗帘是如此之大,以至于我能够凝视他们周围,看到舞台上我旁边的那个人,他正在发出所有的噪音。当然,是罗伊·奥谢罗夫。
The drapes were such that I was able to peer around them and see the fellow on the stage next to mine, who was making all the noise. Of course, it was Roy Osherove.
现在,认识我的人都知道,如果我有心情的话,我可能会在一场关于软件的技术演讲中突然唱起歌来。因此,当我转向观众时,我心想,这个奥谢罗夫家伙是志同道合的人,我必须更好地了解他。
Now, those of you who know me know that breaking into song in the middle of a technical talk about software is something that I might just do, if the mood struck me. So as I turned back to my audience, I thought to myself that this Osherove fellow was a kindred spirit, and I’d have to get to know him better.
我所做的就是更好地了解他。事实上,他为我最近的书《The Clean Coder》做出了重大贡献,并花了三天时间与我共同教授 TDD 课程。我与罗伊的经历都是非常积极的,我希望还有更多。
And getting to know him better is just what I did. In fact, he made a significant contribution to my most recent book, The Clean Coder, and spent three days with me co-teaching a TDD class. My experiences with Roy have all been very positive, and I hope there are many more.
我预测你在阅读这本书时与罗伊的经历也会非常积极,因为这本书很特别。
I predict that your experience with Roy, in the reading of this book, will be very positive as well, because this book is something special.
你读过米切纳的小说吗?我没有;但我听说它们都是从“原子”开始的。你手里拿着的这本书不是詹姆斯·米切纳的小说,但它确实从原子开始——单元测试的原子。
Have you ever read a Michener novel? I haven’t; but I’ve been told that they all start at “the atom.” The book you’re holding isn’t a James Michener novel, but it does start at the atom—the atom of unit testing.
当您浏览前面几页时,不要被误导。这不仅仅是单元测试的介绍。它就是这样开始的,如果你有经验,你可以浏览前面的章节。随着本书的进展,各章开始相互叠加,积累了相当惊人的深度。事实上,当我读到最后一章时(不知道这是最后一章),我心想下一章将讨论世界和平——因为,我的意思是,在解决了引入单位的问题之后,你还能去哪里?使用旧的遗留系统测试顽固的组织?
Don’t be misled as you thumb through the early pages. This is not a mere introduction to unit testing. It starts that way, and if you’re experienced you can skim those first chapters. As the book progresses, the chapters start to build on each other into a rather startling accumulation of depth. Indeed, as I read the last chapter (not knowing it was the last chapter), I thought to myself that the next chapter would be dealing with world peace—because, I mean, where else can you go after solving the problem of introducing unit testing into obstinate organizations with old legacy systems?
这本书是技术性的——技术性很强。有很多代码。这是好事。但罗伊并不局限于技术。他时不时地拿出吉他,唱起歌来,讲述他过去的职业轶事,或者对设计的意义或集成的定义进行哲学性的阐述。他似乎很喜欢给我们讲述他在 2006 年黑暗的过去所做的一些非常糟糕的事情的故事。
This book is technical—deeply technical. There’s a lot of code. That’s a good thing. But Roy doesn’t restrict himself to the technical. From time to time he pulls out his guitar and breaks into song as he tells anecdotes from his professional past or waxes philosophical about the meaning of design or the definition of integration. He seems to relish regaling us with stories about some of the things he did really badly in the deep, dark past of 2006.
哦,不用太担心代码都是用 C# 编写的。我的意思是,谁能区分 C# 和 Java 之间的区别呢?正确的?除此之外,这并不重要。他可能使用 C# 作为表达意图的工具,但本书中的课程也适用于 Java、C、Ruby、Python、PHP 或任何其他编程语言(COBOL 除外)。
Oh, and don’t be too concerned that the code is all in C#. I mean, who can tell the difference between C# and Java anyway? Right? And besides, it just doesn’t matter. He may use C# as a vehicle to communicate his intent, but the lessons in this book also apply to Java, C, Ruby, Python, PHP, or any other programming language (except, perhaps COBOL).
如果您是单元测试和测试驱动开发的新手,或者是老手,您会发现本书适合您。因此,准备好享受 Roy 为您唱的歌曲“单元测试的艺术”吧。
If you’re a newcomer to unit testing and test-driven development, or if you’re an old hand at it, you’ll find this book has something for you. So get ready for a treat as Roy sings you the song, “The Art of Unit Testing.”
And Roy, please tune that guitar!
——罗伯特·C·马丁(鲍勃叔叔)
cleancoder.com
—Robert C. Martin (Uncle Bob)
cleancoder.com
当 Roy Osherove 告诉我他正在写一本关于单元测试的书时,我很高兴听到。多年来,测试模因在业界一直在兴起,但有关单元测试的可用材料相对缺乏。当我查看我的书架时,我看到专门关于测试驱动开发的书籍和一般关于测试的书籍,但到目前为止还没有关于单元测试的全面参考资料 - 没有一本书介绍该主题并从一开始就指导读者采取广泛接受的最佳实践的步骤。这是事实,这是令人震惊的。单元测试并不是一种新做法。我们是如何走到这一步的?
When Roy Osherove told me that he was working on a book about unit testing, I was very happy to hear it. The testing meme has been rising in the industry for years, but there has been a relative dearth of material available about unit testing. When I look at my bookshelf, I see books that are about test-driven development specifically and books about testing in general, but until now there has been no comprehensive reference for unit testing—no book that introduces the topic and guides the reader from first steps to widely accepted best practices. The fact that this is true is stunning. Unit testing isn’t a new practice. How did we get to this point?
说我们工作在一个非常年轻的行业几乎是陈词滥调,但这是事实。不到 100 年前,数学家为我们的工作奠定了基础,但在过去 60 年里,我们的硬件速度才足以利用他们的见解。我们行业的理论与实践之间最初存在差距,我们现在才发现它如何影响我们的领域。
It’s almost a cliché to say that we work in a very young industry, but it’s true. Mathematicians laid the foundations of our work less than 100 years ago, but we’ve only had hardware fast enough to exploit their insights for the last 60 years. There was an initial gap between theory and practice in our industry, and we’re only now discovering how it has impacted our field.
在早期,机器周期非常昂贵。我们分批运行程序。程序员有一个预定的时间段,他们必须将程序打入一副纸牌中,然后步行到机房。如果你的程序不正确,你就会浪费时间,所以你用铅笔和纸仔细检查你的程序,在心里计算出所有的场景,所有的边缘情况。我怀疑自动化单元测试的概念是否可以想象。当你可以用机器来解决它要解决的问题时,为什么还要使用机器进行测试呢?稀缺使我们陷入黑暗。
In the early days, machine cycles were expensive. We ran programs in batches. Programmers had a scheduled time slot, and they had to punch their programs into decks of cards and walk them to the machine room. If your program wasn’t right, you lost your time, so you desk-checked your program with pencil and paper, mentally working out all of the scenarios, all of the edge cases. I doubt the notion of automated unit testing was even imaginable. Why use the machine for testing when you could use it to solve the problems it was meant to solve? Scarcity kept us in the dark.
后来,机器变得更快,我们沉迷于交互式计算。我们可以只输入代码并随心所欲地更改它。桌面检查代码的想法逐渐消失,我们失去了早年的一些纪律。我们知道编程很困难,但这只是意味着我们必须在计算机上花费更多时间,更改线条和符号,直到找到有效的魔法咒语。
Later, machines became faster and we became intoxicated with interactive computing. We could just type in code and change it on a whim. The idea of desk-checking code faded away, and we lost some of the discipline of the early years. We knew programming was hard, but that just meant that we had to spend more time at the computer, changing lines and symbols until we found the magical incantation that worked.
我们从匮乏走向过剩,错过了中间立场,但现在我们正在重新获得它。自动化单元测试将桌面检查规则与对计算机作为开发资源的新认识结合起来。我们可以用我们开发的语言编写自动化测试来检查我们的工作——不仅仅是一次,而是尽可能频繁地运行它们。我认为在软件开发中没有任何其他实践如此强大。
We went from scarcity to surplus and missed the middle ground, but now we’re regaining it. Automated unit testing marries the discipline of desk-checking with a newfound appreciation for the computer as a development resource. We can write automated tests in the language we develop in to check our work—not just once, but as often as we’re able to run them. I don’t think there is any other practice that’s quite as powerful in software development.
2009 年,当我撰写本文时,我很高兴看到罗伊的书出版。这是一本实用指南,可帮助您入门,并在您执行测试任务时作为很好的参考。《单元测试的艺术》并不是一本关于理想化场景的书。它教您如何测试现场存在的代码,如何利用广泛使用的框架,以及最重要的是,如何编写更容易测试的代码。
As I write this, in 2009, I’m happy to see Roy’s book come into print. It’s a practical guide that will help you get started and also serve as a great reference as you go about your testing tasks. The Art of Unit Testing isn’t a book about idealized scenarios. It teaches you how to test code as it exists in the field, how to take advantage of widely used frameworks, and, most importantly, how to write code that’s far easier to test.
《单元测试的艺术》是一个重要的标题,本应该在几年前就写出来,但我们当时还没有准备好。我们现在准备好了。享受。
The Art of Unit Testing is an important title that should have been written years ago, but we weren’t ready then. We are ready now. Enjoy.
—Michael Feathers
Object Mentor
我参与过的最失败的项目之一是单元测试。或者说我是这么想的。我带领一群程序员创建一个计费应用程序,我们以完全测试驱动的方式进行 - 编写测试,然后编写代码,看到测试失败,使测试通过,重构,然后从头开始再次。
One of the biggest failed projects I worked on had unit tests. Or so I thought. I was leading a group of programmers creating a billing application, and we were doing it in a fully test-driven manner—writing the test, then writing the code, seeing the test fail, making the test pass, refactoring, and starting all over again.
该项目的前几个月非常棒。一切进展顺利,我们的测试证明我们的代码有效。但随着时间的推移,要求发生了变化。我们被迫更改代码以适应这些新要求,而当我们这样做时,测试就会失败,必须进行修复。代码仍然有效,但我们编写的测试非常脆弱,即使代码运行良好,代码中的任何微小更改都会破坏它们。更改类或方法中的代码成为一项艰巨的任务,因为我们还必须修复所有相关的单元测试。
The first few months of the project were great. Things were going well, and we had tests that proved that our code worked. But as time went by, requirements changed. We were forced to change our code to fit those new requirements, and when we did, tests broke and had to be fixed. The code still worked, but the tests we wrote were so brittle that any little change in our code broke them, even though the code was working fine. It became a daunting task to change code in a class or method because we also had to fix all the related unit tests.
更糟糕的是,一些测试变得无法使用,因为编写它们的人离开了项目,并且没有人知道如何维护测试或他们正在测试什么。我们给单元测试方法起的名称不够清晰,而且我们的测试依赖于其他测试。项目启动不到六个月,我们就放弃了大部分测试。
Worse yet, some tests became unusable because the people who wrote them left the project, and no one knew how to maintain the tests or what they were testing. The names we gave our unit testing methods weren’t clear enough, and we had tests relying on other tests. We ended up throwing out most of the tests less than six months into the project.
该项目惨遭失败,因为我们让自己编写的测试弊大于利。从长远来看,它们花费的维护和理解时间比它们为我们节省的时间还要多,所以我们停止使用它们。我转向其他项目,在这些项目中,我们在编写单元测试方面做得更好,并且使用它们取得了一些巨大的成功,节省了大量的调试和集成时间。自从第一个失败的项目以来,我一直在编译单元测试的最佳实践并将其用于后续项目。我在我从事的每个项目中都找到了一些更多的最佳实践。
The project was a miserable failure because we let the tests we wrote do more harm than good. They took more time to maintain and understand than they saved us in the long run, so we stopped using them. I moved on to other projects, where we did a better job writing our unit tests, and we had some great successes using them, saving huge amounts of debugging and integration time. Since that first failed project, I’ve been compiling best practices for unit tests and using them on subsequent projects. I find a few more best practices with every project I work on.
无论您使用什么语言或集成开发环境,理解如何编写单元测试以及如何使它们可维护、可读和可信都是本书的主题。本书涵盖了编写单元测试的基础知识,接着介绍了交互测试的基础知识,并介绍了在现实世界中编写、管理和维护单元测试的最佳实践。
Understanding how to write unit tests—and how to make them maintainable, readable, and trustworthy—is what this book is about, no matter what language or integrated development environment you work with. This book covers the basics of writing a unit test, moves on to the basics of interaction testing, and introduces best practices for writing, managing, and maintaining unit tests in the real world.
当曼宁请我帮助完成一本即将完成的关于单元测试的书时,我最初的想法是拒绝。毕竟,我已经有了自己的关于单元测试的书,那么我为什么要参与别人的项目呢?但当我意识到这本书正是 Roy 的《单元测试的艺术》时,我改变了主意。《单元测试的艺术》第一版是我读到的关于该主题的第一本书之一,它帮助塑造了我对单元测试的看法。我很荣幸能为这部重要著作的第三版做出贡献。
When Manning asked me to help complete a book on unit testing that was nearly finished, my initial thought was to decline. After all, I already had my own book on unit testing, so why should I work on someone else’s project? But I changed my mind when I realized that the book in question was none other than Roy’s The Art of Unit Testing. The first edition of The Art of Unit Testing was one of the first books I read on the topic, and it helped shape my views on unit testing. I feel honored to contribute to the third edition of this momentous work.
我个人认为这本书是单元测试主题的优秀介绍。一旦您完成并准备好深入研究,请拿起我的书《单元测试原理、实践和模式》(Manning,2020)。
I personally see this book as an excellent introduction to the subject of unit testing. Once you have completed it and are ready to delve deeper, pick up my book, Unit Testing Principles, Practices, and Patterns (Manning, 2020).
我们要感谢原稿的许多审稿人,他们的反馈帮助我们改进了本书。感谢 Aboudou Samadou Sare、Adhir Ramjiawan、Adriaan Beiertz、Alain Lompo、Barnaby Norman、Charles Lam、Conor Redmond、Daut Morina、Esref Durna、Foster Haines、Harinath Mallepally、Jared Duncan、Jason Hales、Jaume López、Jeremy Chen、Joel霍尔姆斯、约翰·拉森、乔纳森·里夫斯、豪尔赫·E·博、肯特·斯皮尔纳、金·加布里埃尔森、马塞尔·范登布林克、马克·格雷厄姆、马特·范·温克尔、马特奥·巴蒂斯塔、马特奥·吉尔多内、迈克·霍尔科姆、奥利弗·科滕、奥诺弗雷·乔治、保罗·罗巴克、巴勃罗Herrera J.、帕特里斯·马尔达格、拉胡尔·莫德普尔、兰吉特·萨海、Rich Yonts、理查德·迈森、罗德里戈·恩西纳斯、罗纳德·博尔曼、萨钦·辛吉、萨曼莎·伯克、桑德·泽格维尔德、萨特·库马尔·萨胡、Shayn Cornwell、Tanya Wilke、汤姆·马登、乌迪特·巴德瓦吉,和瓦迪姆·图尔科夫。
We would like to thank the many reviewers of the manuscript, whose feedback helped us to improve the book. Thanks go to Aboudou Samadou Sare, Adhir Ramjiawan, Adriaan Beiertz, Alain Lompo, Barnaby Norman, Charles Lam, Conor Redmond, Daut Morina, Esref Durna, Foster Haines, Harinath Mallepally, Jared Duncan, Jason Hales, Jaume López, Jeremy Chen, Joel Holmes, John Larsen, Jonathan Reeves, Jorge E. Bo, Kent Spillner, Kim Gabrielsen, Marcel van den Brink, Mark Graham, Matt Van Winkle, Matteo Battista, Matteo Gildone, Mike Holcomb, Oliver Korten, Onofrei George, Paul Roebuck, Pablo Herrera J., Patrice Maldague, Rahul Modpur, Ranjit Sahai, Rich Yonts, Richard Meinsen, Rodrigo Encinas, Ronald Borman, Sachin Singhi, Samantha Berk, Sander Zegveld, Satej Kumar Sahu, Shayn Cornwell, Tanya Wilke, Tom Madden, Udit Bhardwaj, and Vadim Turkov.
一本成功的书的制作需要很多人的参与。我们要感谢 Manning 收购编辑 Michael Stephens、开发编辑 Connor O'Brien、技术开发编辑 Mike Shepard、技术校对者 Jean-François Morin 以及评论编辑 Adriana Sabo 和 Dunja Nikitović。我们还要感谢曼宁公司的其他所有人,他们参与了第三版的制作和幕后工作。
Many hands go into the making of a successful book. We would like to thank Manning acquisitions editor Michael Stephens, development editor Connor O’Brien, technical development editor Mike Shepard, technical proofreader Jean-François Morin, and review editors Adriana Sabo and Dunja Nikitović. We also thank everyone else at Manning who worked on the third edition in production and behind the scenes.
最后感谢曼宁抢先体验计划中本书的早期读者在在线论坛上提出的评论。你帮助塑造了这本书。
A final word of thanks goes to the early readers of the book in Manning’s Early Access Program for their comments in the online forum. You helped shape the book.
我听过任何人(我忘了是谁)说过的关于学习的最聪明的事情之一就是,要真正学习某些东西,就要教它。编写本书第一版并于 2009 年出版对我来说绝对是一次真正的学习经历。我最初写这本书是因为我厌倦了一遍又一遍地回答同样的问题。但还有其他原因。我想尝试一些新的东西;我想尝试一个实验;我想知道我能从写一本书中学到什么——任何一本书。我认为单元测试是我所擅长的。诅咒是,你的经验越多,你就越觉得自己愚蠢。
One of the smartest things I ever heard anyone say about learning (and I forget who it was) is that to truly learn something, teach it. Writing the first edition of this book and publishing it in 2009 was nothing short of a true learning experience for me. I initially wrote the book because I got tired of answering the same questions over and over again. But there were other reasons, too. I wanted to try something new; I wanted to try an experiment; I wondered what I could learn from writing a book—any book. Unit testing was what I was good at, I thought. The curse is that the more experience you have, the more stupid you feel.
今天我不同意第一版中的某些部分,例如,单元指的是方法。这根本不是真的。正如我在第三版的第一章中讨论的那样,一个单元就是一个工作单元。它可以小到一个方法,也可以大到几个类(可能是程序集),并且还有其他一些事情发生了变化,您接下来将了解到。
There are parts of the first edition that today I do not agree with—for example, that a unit refers to a method. That’s not true at all. A unit is a unit of work, as I discuss in chapter 1 of this third edition. It can be as small as a method, or as big as several classes (possibly assemblies), and there are other things as well that have changed, as you will learn next.
在第三版中,我们从 .NET 切换到 JavaScript 和 TypeScript。当然,所有相关的工具和框架也都得到了更新。例如,我们使用 Jest 代替 NUnit 测试运行器和 NSubstitute,既作为单元测试框架又作为模拟库。
In this third edition, we switched from .NET to JavaScript and TypeScript. All the related tools and frameworks got updated, too, of course. For example, instead of NUnit test runner and NSubstitute, we used Jest, both as a unit testing framework and as a mocking library.
We added more techniques to the chapter about implementing unit testing at the organizational level.
我们在书中展示的代码中有很多设计更改。它们主要与动态类型语言(例如 JavaScript)的使用相关,但我们也在 TypeScript 的帮助下讨论静态类型技术。
There are plenty of design changes in the code we show in the book. They are mostly related to the use of dynamically typed languages such as JavaScript, but we talk about statically typed techniques as well with the help of TypeScript.
关于测试可信性、可维护性和可读性的讨论已扩展为三个单独的章节。我们还添加了关于测试策略的新章节:如何在不同的测试类型之间做出决定以及使用哪些技术。
The discussion about test trustworthiness, maintainability, and readability has been expanded into three separate chapters. We also added a new chapter about testing strategies: how to decide between different test types and what techniques to use.
这本书适合任何编写代码并有兴趣学习单元测试最佳实践的人。所有示例都是用 JavaScript 和 TypeScript 编写的,因此 JavaScript 开发人员会发现这些示例特别有用。但我们教授的课程同样适用于大多数(如果不是全部)面向对象和静态类型语言(C#、VB.NET、Java 和 C++,仅举几例)。如果您是架构师、开发人员、团队负责人、QA 工程师(编写代码)或新手程序员,这本书应该很适合您。
The book is for anyone who writes code and is interested in learning best practices for unit testing. All the examples are written in JavaScript and TypeScript, so JavaScript developers will find the examples particularly useful. But the lessons we teach apply equally to most, if not all, object-oriented and statically typed languages (C#, VB.NET, Java, and C++, to name a few). If you’re an architect, developer, team lead, QA engineer (who writes code), or novice programmer, this book should suit you well.
如果您从未编写过单元测试,最好从头到尾阅读本书,以便获得全面的了解。如果您有经验,您应该可以轻松地跳入您认为合适的章节。本书分为四个部分。
If you’ve never written a unit test, it’s best to read this book from start to finish so you get the full picture. If you have experience, you should feel comfortable jumping into the chapters as you see fit. The book is divided into four parts.
第 1 部分将带您从 0 到 60 编写单元测试。第 1 章和第 2 章介绍了基础知识,例如如何使用测试框架 (Jest),并介绍了自动化测试概念,例如测试库、断言库和测试运行器。他们还介绍了断言、忽略测试、工作单元测试、单元测试的三种最终结果以及它们所需的三种测试类型的思想:值测试、基于状态的测试和交互测试。
Part 1 takes you from 0 to 60 in writing unit tests. Chapters 1 and 2 cover the basics, such as how to use a testing framework (Jest), and they introduce automated test concepts, such as test libraries, assertion libraries, and test runners. They also introduce the ideas of asserts, ignoring tests, unit-of-work testing, the three types of end results of a unit test, and the three types of tests you need for them: value tests, state-based tests, and interaction tests.
第 2 部分讨论打破依赖关系的高级技术:模拟对象、桩、隔离框架以及重构代码以使用它们的模式。第 3 章介绍了桩的概念,并展示了如何手动创建和使用它们。第 4 章介绍了模拟对象的交互测试。第 5 章融合了这两个概念,并展示了隔离框架如何结合这两个想法并允许它们自动化。第 6 章深入了解如何测试异步代码。
Part 2 discusses advanced techniques for breaking dependencies: mock objects, stubs, isolation frameworks, and patterns for refactoring your code to use them. Chapter 3 introduces the idea of stubs and shows how to manually create and use them. Chapter 4 introduces interaction testing with mock objects. Chapter 5 merges these two concepts and shows how isolation frameworks combine these two ideas and allow them to be automated. Chapter 6 dives deeper into understanding how to test asynchronous code.
第 3 部分介绍组织测试代码的方法、运行和重构其结构的模式以及编写测试时的最佳实践。第 7 章讨论编写值得信赖的测试的技术。第 8 章讨论了创建可维护测试的单元测试最佳实践。
Part 3 is about ways to organize test code, patterns for running and refactoring its structure, and best practices when writing tests. Chapter 7 discusses techniques for writing tests that you can trust. Chapter 8 discusses best practices in unit testing for creating maintainable tests.
第 4 部分是关于如何在组织中实施变更以及如何处理现有代码。第 9 章是关于测试可读性的。第 10 章展示了如何制定测试策略。第 11 章讨论了在尝试将单元测试引入组织时遇到的问题和解决方案,并确定并回答了在此过程中可能会提出的一些问题。第 12 章讨论了将单元测试引入到遗留代码中。它确定了几种确定从哪里开始测试的方法,并讨论了一些用于测试不可测试代码的工具。
Part 4 is all about how to implement change in an organization and how to work on existing code. Chapter 9 is about test readability. Chapter 10 shows how to develop a testing strategy. Chapter 11 discusses problems and solutions you’d encounter when trying to introduce unit testing into an organization, and it identifies and answers some questions you might be asked in the course of such an effort. Chapter 12 talks about introducing unit testing into legacy code. It identifies a couple of ways to determine where to begin testing and discusses some tools for testing untestable code.
The appendix has a list of monkey-patching techniques you might find useful in your testing efforts.
清单或文本中的所有源代码都是这样的,fixed-width font以区别于普通文本。在清单中,bold code表示与上一个示例相比已更改的代码或将在下一个示例中更改的代码。在许多清单中,代码都带有注释以指出关键概念。
All source code in listings or in the text is in a fixed-width font like this to distinguish it from ordinary text. In listings, bold code indicates code that has changed from the previous example or that will change in the next example. In many listings, the code is annotated to point out the key concepts.
您可以从 GitHub (https://github.com/royosherove/aout3-samples)以及出版商的网站(https://www.manning.com/books/the-art-)下载本书的源代码。单元测试第三版。您可以从本书的 liveBook(在线)版本获取可执行代码片段:https://livebook.manning.com/book/the-art-of-unit-testing-third-edition。
You can download the source code for this book from GitHub at https://github.com/royosherove/aout3-samples, as well as from the publisher’s website at https://www.manning.com/books/the-art-of-unit-testing-third-edition. You can get executable snippets of code from the liveBook (online) version of this book at https://livebook.manning.com/book/the-art-of-unit-testing-third-edition.
要使用本书中的代码,您需要 VS Code(免费)。您还需要 Jest(一个开源免费框架)和其他相关工具。提到的所有工具都是免费的、开源的,或者有试用版,您可以在阅读本书时免费使用。
To use the code in this book, you’ll need VS Code (which is free). You’ll also need Jest (an open source and free framework) and other tools that will be referenced where they’re relevant. All the tools mentioned are either free, open source, or have trial versions you can use freely as you read this book.
购买《单元测试的艺术》第三版,包括免费访问曼宁的在线阅读平台 liveBook。使用 liveBook 独有的讨论功能,您可以在全局或特定章节或段落中附加评论。您可以轻松地为自己做笔记、提出和回答技术问题以及从作者和其他用户那里获得帮助。要访问论坛,请访问https://livebook.manning.com/book/the-art-of-unit-testing-third-edition/discussion。您还可以访问https://livebook.manning.com/discussion了解有关 Manning 论坛和行为规则的更多信息。
Purchase of The Art of Unit Testing, Third Edition, includes free access to liveBook, Manning’s online reading platform. Using liveBook’s exclusive discussion features, you can attach comments to the book globally or to specific sections or paragraphs. It’s a snap to make notes for yourself, ask and answer technical questions, and receive help from the author and other users. To access the forum, go to https://livebook.manning.com/book/the-art-of-unit-testing-third-edition/discussion. You can also learn more about Manning’s forums and the rules of conduct at https://livebook.manning.com/discussion.
曼宁对读者的承诺是提供一个场所,使个人读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与任何具体数量的承诺,他们对论坛的贡献仍然是自愿的(并且是无偿的)。我们建议您尝试问他们一些具有挑战性的问题,以免他们的兴趣消失!只要该书还在印刷,就可以从出版商的网站访问论坛和之前讨论的档案。
Manning’s commitment to our readers is to provide a venue where a meaningful dialogue between individual readers and between readers and authors can take place. It is not a commitment to any specific amount of participation on the part of the authors, whose contribution to the forum remains voluntary (and unpaid). We suggest you try asking them some challenging questions, lest their interest stray! The forum and the archives of previous discussions will be accessible from the publisher’s website as long as the book is in print.
Roy 也是《Elastic Leadership: Growing Self-organizing Teams 》(可在www.manning.com/books/elastic-leadership上获取)和《Notes to a Software Team Leader: Growing Self-Organizing Teams》(Team Agile Publishing,2014)的作者。
Roy is also the author of Elastic Leadership: Growing Self-organizing Teams, available at www.manning.com/books/elastic-leadership, and Notes to a Software Team Leader: Growing Self-Organizing Teams (Team Agile Publishing, 2014).
与本书相关的团队领导者博客可在http://5whys.com上找到。
A blog for team leaders related to this book is available at http://5whys.com.
Roy 的在线视频 TDD 大师班可在https://courses.osherove.com/courses上观看。
An online video TDD Master Class by Roy is available at https://courses.osherove.com/courses.
许多有关单元测试的免费视频可以在http://ArtOfUnitTesting.com和http://Osherove.com/Videos上找到。
Many free videos about unit testing are available at http://ArtOfUnitTesting.com and http://Osherove.com/Videos.
Roy 不断在世界各地进行培训和咨询。您可以通过http://contact.osherove.com联系他,预订您自己公司的培训。
Roy is continuously training and consulting around the world. You can contact him at http://contact.osherove.com to book training at your own company.
And you can follow him on X at @RoyOsherove.
Vladimir 也是《单元测试原理、实践和模式》一书的作者,您可以在https://www.manning.com/books/unit-testing找到该书。
Vladimir is also the author of Unit Testing Principles, Practices, and Patterns, which you can find at https://www.manning.com/books/unit-testing.
为努力学习单元测试和领域驱动设计的开发人员提供的博客: https: //enterprisecraftsmanship.com/。
A blog for developers striving to learn about unit testing and domain-driven design: https://enterprisecraftsmanship.com/.
有关 Pluralsight 的视频课程可在https://bit.ly/ps-all上获取。
Video courses on Pluralsight are available at https://bit.ly/ps-all.
And you can follow him on X at @vkhorikov.
Roy Osherove是 ALT.NET 最初的组织者之一,之前曾在 Typemock 担任首席架构师。他为世界各地的团队提供有关单元测试和测试驱动开发的温和艺术的咨询和培训,并在5whys.com上教授团队领导者如何更好地领导。Roy 在 @RoyOsherove 发推文,并在ArtOfUnitTesting.com上有许多有关单元测试的视频。您还可以在Osherove.com上预约他参加讲座和培训。
Roy Osherove is one of the original ALT.NET organizers and previously worked at Typemock as a chief architect. He consults and trains teams worldwide on the gentle art of unit testing and test-driven development, and he teaches team leaders how to lead better at 5whys.com. Roy tweets at @RoyOsherove and has many videos about unit testing at ArtOfUnitTesting.com. He can also be booked for talks and training at Osherove.com.
Vladimir Khorikov是 Microsoft MVP、博主和 Pluralsight 作者。他专业从事软件开发已有 10 多年,包括指导团队了解单元测试的细节。Vladimir 是 Manning 出版的《单元测试、原理、实践和模式》一书的作者,他撰写了多篇热门博客文章系列和有关单元测试主题的在线培训课程。他的教学风格的最大优点,也是学生经常称赞的一点,是他倾向于拥有强大的理论背景,然后将其应用到实际例子中。他的博客位于EnterpriseCraftsmanship.com。
Vladimir Khorikov is a Microsoft MVP, blogger, and Pluralsight author. He has been professionally involved in software development for more than 10 years, including mentoring teams on the ins and outs of unit testing. Vladimir is the author of Unit Testing, Principles, Practices, and Patterns from Manning, and he has written several popular blog post series and an online training course on the topic of unit testing. The biggest advantage of his teaching style, and the one students often praise, is his tendency to have a strong theoretic background, which he then applies to practical examples. His blog is at EnterpriseCraftsmanship.com.
《单元测试的艺术》第三版封面上的人物是“Japonais encostume de cérémonie”,即“穿着礼服的日本男人”。插图取自 James Prichard 的《人类自然史》,这是一本 1847 年在英国出版的手绘彩色版画书。它是我们的封面设计师在旧金山的一家古董店发现的。
The figure on the cover of The Art of Unit Testing, Third Edition, is a “Japonais en costume de cérémonie,” or a “Japanese man in ceremonial dress.” The illustration is taken from James Prichard’s Natural History of Man, a book of hand-colored lithographs published in England in 1847. It was found by our cover designer in an antique shop in San Francisco.
在那个时代,人们很容易通过衣着来判断他们住在哪里、从事什么行业或生活中的地位。曼宁以几个世纪前丰富的地域文化多样性为基础的书籍封面来颂扬计算机行业的创造力和主动性,并通过像这本书这样的收藏中的图片复活。
In those days, it was easy to identify where people lived and what their trade or station in life was just by their dress. Manning celebrates the inventiveness and initiative of the computer business with book covers based on the rich diversity of regional culture centuries ago, brought back to life by pictures from collections such as this one.
This part of the book covers the basics of unit testing.
在第一章中,我将定义什么是“单元”以及“好的”单元测试意味着什么,并且我将比较单元测试和集成测试。然后我们将了解测试驱动开发及其在单元测试中的作用。
In chapter 1, I’ll define what a “unit” is and what “good” unit testing means, and I’ll compare unit testing with integration testing. Then we’ll look at test-driven development and its role in relation to unit testing.
在第 2 章中,您将尝试使用 Jest(一种常见的 JavaScript 测试框架)编写您的第一个单元测试。您将了解 Jest 的基本 API、如何断言以及如何连续执行测试。
You’ll take a stab at writing your first unit test using Jest (a common JavaScript test framework) in chapter 2. You’ll get to know Jest’s basic API, how to assert things, and how to execute tests continuously.
手动测试很糟糕。您编写代码,在调试器中运行它,在应用程序中按下所有正确的键以使事情顺利进行,然后在下次编写新代码时重复所有这些。并且您必须记住检查可能受新代码影响的所有其他代码。更多的手工工作。伟大的。
Manual tests suck. You write your code, you run it in the debugger, you hit all the right keys in your app to get things just right, and then you repeat all this the next time you write new code. And you have to remember to check all the other code that might have been affected by the new code. More manual work. Great.
完全手动进行测试和回归测试,像猴子一样一次又一次地重复相同的操作,很容易出错,而且很耗时,人们似乎讨厌这样做,就像软件开发中讨厌任何事情一样。这些问题可以通过工具和我们永久使用它的决定来缓解,通过编写自动化测试来节省我们宝贵的时间和调试的痛苦。集成和单元测试框架可帮助开发人员使用一组已知的 API 更快地编写测试、自动执行这些测试并轻松查看这些测试的结果。他们永远不会忘记!我假设你正在读这本书,要么是因为你有同样的感觉,要么是因为有人强迫你读这本书,而有人也有同样的感觉。或者也许是有人强迫你读这本书。没关系。如果您认为重复的手动测试很棒,那么这本书将很难读。假设您想学习如何编写良好的单元测试。
Doing tests and regression testing completely manually, repeating the same actions again and again like a monkey, is error prone and time consuming, and people seem to hate doing that as much as anything can be hated in software development. These problems are alleviated by tooling and our decision to use it for good, by writing automated tests that save us precious time and debugging pain. Integration and unit testing frameworks help developers write tests more quickly with a set of known APIs, execute those tests automatically, and review the results of those tests easily. And they never forget! I’m assuming you’re reading this book because either you feel the same way, or because someone forced you to read it, and that someone feels the same way. Or maybe that someone was forced to force you into reading this book. Doesn’t matter. If you believe repetitive manual testing is awesome, this book will be very difficult to read. The assumption is that you want to learn how to write good unit tests.
本书还假设您知道如何使用 JavaScript 或 TypeScript 编写代码,至少使用 ECMAScript 6 (ES6) 功能,并且熟悉节点包管理器 (npm)。另一个假设是您熟悉 Git 源代码管理。如果您以前见过 github.com 并且知道如何从那里克隆存储库,那么您就可以开始了。
This book also assumes that you know how to write code using JavaScript or TypeScript, using at least ECMAScript 6 (ES6) features, and that you are comfortable with node package manager (npm). Another assumption is that you are familiar with Git source control. If you’ve seen github.com before and you know how to clone a repository from there, you are good to go.
尽管本书的所有代码清单都是 JavaScript 和 TypeScript 语言,但您不必是 JavaScript 程序员也可以阅读本书。这本书的前几个版本是用 C# 编写的,我发现其中大约 80% 的模式很容易转移。即使您来自 Java、.NET、Python、Ruby 或其他语言,您也应该能够阅读本书。图案只是图案。该语言用于演示这些模式,但它们不是特定于语言的。
Although all the book’s code listings are in JavaScript and TypeScript, you don’t have to be a JavaScript programmer to read this book. The previous editions of this book were in C#, and I’ve found that about 80% of the patterns there have transferred over quite easily. You should be able to read this book even if you come from Java, .NET, Python, Ruby, or other languages. The patterns are just patterns. The language is used to demonstrate those patterns, but they are not language-specific.
总是有第一步:第一次编写程序、第一次失败的项目以及第一次成功实现自己想要完成的目标。你永远不会忘记你的第一次,我希望你也不会忘记你的第一次测试。
There’s always a first step: the first time you wrote a program, the first time you failed a project, and the first time you succeeded in what you were trying to accomplish. You never forget your first time, and I hope you won’t forget your first tests.
您可能遇到过某种形式的测试。您最喜欢的一些开源项目附带捆绑的“测试”文件夹 - 您可以将它们放在您自己的工作项目中。您可能已经自己编写了一些测试,甚至可能记得它们是糟糕的、笨拙的、缓慢的或难以维护的。更糟糕的是,你可能会觉得它们毫无用处并且浪费时间。(可悲的是,很多人都这样做。) 或者,您可能已经在单元测试方面获得了很好的初次体验,并且您正在阅读这本书,看看您可能还缺少什么。
You may have come across tests in some form. Some of your favorite open source projects come with bundled “test” folders—you have them in your own projects at work. You might have already written a few tests yourself, and you may even remember them as being bad, awkward, slow, or unmaintainable. Even worse, you might have felt they were useless and a waste of time. (Many people sadly do.) Or you may have had a great first experience with unit tests, and you’re reading this book to see what more you might be missing.
本章将分析单元测试的“经典”定义,并将其与集成测试的概念进行比较。这种区别让许多人感到困惑,但学习它非常重要,因为正如您将在本书后面学到的那样,将单元测试与其他类型的测试分开对于在测试失败或通过时保持高度信心至关重要。
This chapter will analyze the “classic” definition of a unit test and compare it to the concept of integration testing. This distinction is confusing to many, but it’s very important to learn, because, as you’ll learn later in the book, separating unit tests from other types of tests can be crucial to having high confidence in your tests when they fail or pass.
我们还将讨论单元测试与集成测试的优缺点,并且我们将对什么是“好的”单元测试制定更好的定义。最后我们将介绍一下测试驱动开发 (TDD),因为它通常与单元测试相关,但它是一项独立的技能,我强烈建议您尝试一下(尽管它不是本书的主题)。在本章中,我还将触及本书其他地方更彻底解释的概念。
We’ll also discuss the pros and cons of unit testing versus integration testing, and we’ll develop a better definition of what might be a “good” unit test. We’ll finish with a look at test-driven development (TDD), because it’s often associated with unit testing but is a separate skill that I highly recommend giving a chance (it’s not the main topic of this book, though). Throughout this chapter, I’ll also touch on concepts that are explained more thoroughly elsewhere in the book.
First, let’s define what a unit test should be.
单元测试并不是软件开发中的新概念。自 20 世纪 70 年代 Smalltalk 编程语言早期以来,它就一直存在,并且一次又一次地证明了自己是开发人员提高代码质量同时更深入地了解模块功能需求的最佳方式之一,类,或者函数。Kent Beck 在 Smalltalk 中引入了单元测试的概念,并将其推广到许多其他编程语言中,使单元测试成为一种非常有用的实践。
Unit testing isn’t a new concept in software development. It’s been floating around since the early days of the Smalltalk programming language in the 1970s, and it proves itself time and time again as one of the best ways a developer can improve code quality while gaining a deeper understanding of the functional requirements of a module, class, or function. Kent Beck introduced the concept of unit testing in Smalltalk, and it has carried on into many other programming languages, making unit testing an extremely useful practice.
要了解我们不想使用什么作为单元测试的定义,让我们以维基百科为起点。我将保留使用它的定义,因为在我看来,它遗漏了许多重要的部分,但由于缺乏其他好的定义,它在很大程度上被许多人接受。我们的定义将在本章中慢慢演变,最终定义出现在第 1.9 节中。
To see what we don’t want to use as our definition of unit testing, let’s look to Wikipedia as a starting point. I’ll use its definition with reservations, because, in my opinion, there are many important parts missing, but it is largely accepted by many for lack of other good definitions. Our definition will slowly evolve throughout this chapter, with the final definition appearing in section 1.9.
单元测试通常是由软件开发人员编写和运行的自动化测试,以确保应用程序的一部分(称为“单元”)满足其设计并按预期运行。在过程式编程中,单元可以是整个模块,但更常见的是单个函数或过程。在面向对象编程中,单元通常是整个接口,例如类或单个方法(https://en.wikipedia.org/wiki/Unit_testing)。
Unit tests are typically automated tests written and run by software developers to ensure that a section of an application (known as the “unit”) meets its design and behaves as intended. In procedural programming, a unit could be an entire module, but it is more commonly an individual function or procedure. In object-oriented programming, a unit is often an entire interface, such as a class, or an individual method (https://en.wikipedia.org/wiki/Unit_testing).
The thing you’ll write tests for is the subject, system, or suite under test (SUT).
定义SUT 代表主题、系统或测试套件,有些人喜欢使用 CUT(组件、类或测试代码)。当您测试某些东西时,您将正在测试的东西称为 SUT。
Definition SUT stands for subject, system, or suite under test, and some people like to use CUT (component, class, or code under test). When you test something, you refer to the thing you’re testing as the SUT.
我们来谈谈单元测试中的“单元”这个词。对我来说,单元代表系统内的“工作单元”或“用例”。一个工作单元有一个开始和一个结束,我称之为入口点和出口点。工作单元的一个简单示例是计算某些内容并返回值的函数。但是,函数还可以在计算过程中使用其他函数、其他模块和其他组件,这意味着工作单元(从入口点到出口点)可以跨越多个函数。
Let’s talk about the word “unit” in unit testing. To me, unit stands for a “unit of work” or a “use case” inside the system. A unit of work has a beginning and an end, which I call an entry point and an exit point. A simple example of a unit of work is a function that calculates something and returns a value. However, a function could also use other functions, other modules, and other components in the calculation process, which means the unit of work (from entry point to exit point), could span more than just a function.
一个工作单元总是有一个入口点和一个或多个出口点。图 1.1 显示了工作单元的简单图。
A unit of work always has an entry point and one or more exit points. Figure 1.1 shows a simple diagram of a unit of work.
Figure 1.1 A unit of work has entry points and exit points.
一个工作单元可以是单个功能、多个功能,甚至多个模块或组件。但它总是有一个我们可以从外部触发的入口点(通过测试或其他生产代码),并且它最终总是会做一些有用的事情。如果它没有做任何有用的事情,我们不妨将其从我们的代码库中删除。
A unit of work can be a single function, multiple functions, or even multiple modules or components. But it always has an entry point that we can trigger from the outside (via tests or other production code), and it always ends up doing something useful. If it doesn’t do anything useful, we might as well remove it from our codebase.
有什么用?代码中发生的一些公开可见的事情:返回值、状态更改或调用外部方,如图 1.2 所示。这些值得注意的行为就是我所说的退出点。
What’s useful? Something publicly noticeable that happens in the code: a return value, a state change, or calling an external party, as shown in figure 1.2. Those noticeable behaviors are what I call exit points.
Figure 1.2 Types of exit points
The following listing shows a quick code example of a simple unit of work.
Listing 1.1 A simple function that we’d like to test
const sum = (数字) => {
const [a, b] = Numbers.split(',');
const 结果 = parseInt(a) + parseInt(b);
返回结果;
};const sum = (numbers) => {
const [a, b] = numbers.split(',');
const result = parseInt(a) + parseInt(b);
return result;
};
该工作单元完全包含在一个函数中。该函数是入口点,并且由于其最终结果返回一个值,因此它也充当出口点。我们在触发工作单元的同一位置获得最终结果,因此入口点也是出口点。
This unit of work is completely encompassed in a single function. The function is the entry point, and because its end result returns a value, it also acts as the exit point. We get the end result in the same place we trigger the unit of work, so the entry point is also the exit point.
如果我们将此函数绘制为一个工作单元,它将如图 1.3 所示。我用作sum(numbers)入口点,而不是numbers,因为入口点是函数签名。参数是通过入口点给出的上下文或输入。
If we drew this function as a unit of work, it would look something like figure 1.3. I used sum(numbers) as the entry point, not numbers, because the entry point is the function signature. The parameters are the context or input given through the entry point.
Figure 1.3 A function that has the same entry point as exit point
The following listing shows a variation on this idea.
Listing 1.2 A unit of work with entry points and exit points
让总计 = 0;
const TotalSoFar = () => {
返回总计;
};
const sum = (数字) => {
const [a, b] = Numbers.split(',');
const 结果 = parseInt(a) + parseInt(b);
总计+=结果; ❶
返回结果;
};let total = 0;
const totalSoFar = () => {
return total;
};
const sum = (numbers) => {
const [a, b] = numbers.split(',');
const result = parseInt(a) + parseInt(b);
total += result; ❶
return result;
};
❶新功能:计算运行总计
❶ New functionality: calculating a running total
This new version of sum has two exit points. It does two things:
它引入了新功能:所有总和的运行总计。totalSoFar它以一种从入口点的调用者可以注意到的方式(通过 )设置模块的状态。
It introduces new functionality: a running total of all the sums. It sets the state of the module in a way that is noticeable (via totalSoFar) from the caller of the entry point.
图 1.4 显示了我如何绘制这个工作单元。您可以将这两个出口点视为来自同一工作单元的两个不同路径或要求,因为它们确实是代码预期要做的两个不同的有用的事情。这也意味着我很可能在这里编写两个不同的单元测试:每个退出点一个。很快我们就会做到这一点。
Figure 1.4 shows how I would draw this unit of work. You can think of these two exit points as two different paths, or requirements, from the same unit of work, because they indeed are two different useful things the code is expected to do. This also means I’d be very likely to write two different unit tests here: one for each exit point. Very soon we’ll do exactly that.
Figure 1.4 A unit of work with two exit points
关于什么totalSoFar?这也是一个切入点吗?是的,可能是,在单独的测试中。我可以编写一个测试来证明totalSoFar在调用返回之前不触发调用0。这将使其成为自己的小工作单元,这完全没问题。通常,一个工作单元(例如sum)可以由更小的单元组成。
What about totalSoFar? Is this also an entry point? Yes, it could be, in a separate test. I could write a test that proves that calling totalSoFar without triggering prior to that call returns 0. That would make it its own little unit of work, which would be perfectly fine. Often one unit of work (such as sum) can be composed of smaller units.
正如您所看到的,我们的测试范围可以改变和变异,但我们仍然可以用入口点和出口点来定义它们。入口点始终是测试触发工作单元的地方。一个工作单元可以有多个入口点,每个入口点由一组不同的测试使用。
As you can see, the scope of our tests can change and mutate, but we can still define them with entry points and exit points. Entry points are always where the test triggers the unit of work. You can have multiple entry points into a unit of work, each used by a different set of tests.
A third version of our example function is shown in the following listing.
Listing 1.3 Adding a logger call to the function
让总计 = 0;
const TotalSoFar = () => {
返回总计;
};
const logger = makeLogger();
const sum = (数字) => {
const [a, b] = Numbers.split(',');
logger.info( ❶
'这是一个非常重要的日志输出', ❶
{ firstNumWas: a, secondaryNumWas: b }); ❶
const 结果 = parseInt(a) + parseInt(b);
总计+=结果;
返回结果;
};let total = 0;
const totalSoFar = () => {
return total;
};
const logger = makeLogger();
const sum = (numbers) => {
const [a, b] = numbers.split(',');
logger.info( ❶
'this is a very important log output', ❶
{ firstNumWas: a, secondNumWas: b }); ❶
const result = parseInt(a) + parseInt(b);
total += result;
return result;
};
您可以看到函数中有一个新的退出点(或要求,或最终结果)。它将某些内容记录到外部实体——可能是文件、控制台或数据库。我们不知道,也不关心。这是第三种退出点:调用第三方。我还喜欢将其称为“调用依赖项”。
You can see that there’s a new exit point (or requirement, or end result) in the function. It logs something to an external entity—perhaps to a file, or the console, or a database. We don’t know, and we don’t care. This is the third type of exit point: calling a third party. I also like to refer to it as “calling a dependency.”
定义依赖关系是我们在单元测试期间无法完全控制的东西。或者,在测试中试图控制它可能会让我们的生活变得痛苦。一些示例包括写入文件的记录器、与网络通信的事物、由其他团队控制的代码、需要很长时间的组件(计算、线程、数据库访问)等等。经验法则是,如果我们可以完全轻松地控制它正在做什么,并且它在内存中运行并且速度很快,那么它就不是依赖项。规则总有例外,但这至少可以帮助您解决 80% 的情况。
DEFINITION A dependency is something we don’t have full control over during a unit test. Or it can be something that trying to control in a test would make our lives miserable. Some examples would include loggers that write to files, things that talk to the network, code that’s controlled by other teams, components that take a long time (calculations, threads, database access), and more. The rule of thumb is that if we can fully and easily control what it’s doing, and it runs in memory, and it’s fast, then it’s not a dependency. There are always exceptions to the rule, but this should get you through 80% of the cases, at least.
图 1.5 显示了我如何绘制具有所有三个出口点的工作单元。此时我们仍在讨论函数大小的工作单元。入口点是函数调用,但现在我们有三个可能的路径或出口点,它们可以做一些有用的事情,并且调用者可以公开验证。
Figure 1.5 shows how I’d draw this unit of work with all three exit points. At this point we’re still discussing a function-sized unit of work. The entry point is the function call, but now we have three possible paths, or exit points, that do something useful and that the caller can verify publicly.
Figure 1.5 Showing three exit points from a function
这就是有趣的地方:对每个出口点进行单独的测试是个好主意。这将使测试更具可读性并且更易于调试或更改,而不会影响其他结果。
Here’s where it gets interesting: it’s a good idea to have a separate test for each exit point. This will make the tests more readable and simpler to debug or change without affecting other outcomes.
We’ve seen that we have three different types of end results:
调用的函数返回一个有用的值(不是未定义的)。如果这是静态类型语言(例如 Java 或 C#),我们会说它是公共的、非 void 函数。
The invoked function returns a useful value (not undefined). If this was in a statically typed language such as Java or C#, we’d say it is a public, non-void function.
There’s a noticeable change to the state or behavior of the system before and after invocation that can be determined without interrogating private state.
有一个对测试无法控制的第三方系统的标注。该第三方系统不返回任何值,或者该值被忽略。(例如:代码调用了不是您编写的第三方日志系统,并且您无法控制其源代码。)
There’s a callout to a third-party system over which the test has no control. That third-party system doesn’t return any value, or that value is ignored. (Example: the code calls a third-party logging system that was not written by you, and you don’t control its source code.)
让我们看看入口点和出口点的概念如何影响单元测试的定义:单元测试是一段代码,它调用一个工作单元并检查一个特定的出口点作为该工作单元的最终结果。如果关于最终结果的假设被证明是错误的,则单元测试失败。单元测试的范围可以小到一个函数,也可以大到多个模块或组件,具体取决于入口点和出口点之间使用了多少个函数和模块。
Let’s see how the idea of entry and exit points affects the definition of a unit test: A unit test is a piece of code that invokes a unit of work and checks one specific exit point as an end result of that unit of work. If the assumptions about the end result turn out to be wrong, the unit test has failed. A unit test’s scope can span as little as a function or as much as multiple modules or components, depending on how many functions and modules are used between the entry point and the exit point.
为什么我要花这么多时间谈论退出点的类型?因为不仅将每个出口点的测试分开是个好主意,而且不同类型的出口点可能需要不同的技术才能成功测试:
Why am I spending so much time talking about types of exit points? Because not only is it a great idea to separate the tests for each exit point, but different types of exit points might require different techniques to test successfully:
基于返回值的退出点(Meszaros 的XUnit 测试模式中的直接输出)应该是最容易测试的退出点。你触发一个入口点,你会得到一些东西,然后你检查你得到的值。
Return-value-based exit points (direct outputs in Meszaros’ XUnit Test Patterns) should be the easiest exit points to test. You trigger an entry point, you get something back, and you check the value you got back.
基于状态的测试(间接输出)通常需要更多的体操。您调用某个内容,然后再进行另一个调用以检查其他内容(或者再次调用前一个内容)以查看一切是否按计划进行。
State-based tests (indirect outputs) usually require a little more gymnastics. You call something, and then you do another call to check something else (or you call the previous thing again) to see if everything went according to plan.
在第三方情况(间接输出)中,我们要跨越的障碍最多。我们还没有讨论过这一点,但这就是我们被迫使用模拟对象之类的东西来用我们可以在测试中控制和询问的东西来替换外部系统。我将在本书后面深入讨论这个想法。
In a third-party situation (indirect outputs), we have the most hoops to jump through. We haven’t discussed this yet, but that’s where we’re forced to use things like mock objects to replace the external system with something we can control and interrogate in our tests. I’ll cover this idea deeply later in the book.
让我们回到第一个最简单的代码版本(清单 1.1)并尝试测试它,好吗?如果我们尝试为此编写一个测试,它会是什么样子?
Let’s go back to the first, simplest version of the code (listing 1.1) and try to test it, shall we? If we were to try to write a test for this, what would it look like?
让我们首先采用图 1.6 的视觉方法。我们的入口点是sum一个名为 的字符串的输入numbers。sum也是我们的退出点,因为我们将从它获取返回值并检查它的值。
Let’s take the visual approach first with figure 1.6. Our entry point is sum with an input of a string called numbers. sum is also our exit point, since we will get a return value back from it and check its value.
Figure 1.6 A visual view of our test
可以在不使用测试框架的情况下编写自动化单元测试。事实上,由于开发人员已经养成了自动化测试的习惯,我看到很多人在发现测试框架之前就这样做了。在本节中,我们将在不使用框架的情况下编写这样的测试,以便您可以将此方法与第 2 章中使用框架进行对比。
It’s possible to write an automated unit test without using a test framework. In fact, because developers have gotten more into the habit of automating their testing, I’ve seen plenty of them doing this before discovering test frameworks. In this section, we’ll write such a test without a framework, so that you can contrast this approach with using a framework in chapter 2.
因此,我们假设测试框架不存在(或者我们不知道它们存在)。我们决定从头开始编写我们自己的小型自动化测试。下面的清单显示了一个使用纯 JavaScript 测试我们自己的代码的非常简单的示例。
So, let’s assume test frameworks don’t exist (or that we don’t know they do). We have decided to write our own little automated test from scratch. The following listing shows a very naive example of testing our own code with plain JavaScript.
Listing 1.4 A very naive test against sum()
const parserTest = () => {
尝试 {
const 结果 = sum('1,2');
如果(结果 === 3){
console.log('parserTest 示例 1通过' );
} 别的 {
throw new Error(`parserTest:预期为 3,但结果为 ${result} `);
}
} 捕获 (e) {
console.error(e.stack);
}
};const parserTest = () => {
try {
const result = sum('1,2');
if (result === 3) {
console.log('parserTest example 1 PASSED');
} else {
throw new Error(`parserTest: expected 3 but was ${result}`);
}
} catch (e) {
console.error(e.stack);
}
};
不,这段代码并不可爱。但这足以解释测试的工作原理。要运行此代码,我们可以执行以下操作:
No, this code is not lovely. But it’s good enough to explain how tests work. To run this code, we can do the following:
"scripts"在package.json的entry下添加一个entry"test"来执行"node mytest.js",然后npm test在命令行执行。
Add an entry under package.json’s "scripts" entry under "test" to execute "node mytest.js" and then execute npm test on the command line.
The following listing shows this.
Listing 1.5 The beginning of our package.json file
{
“名称”:“aout3-样本”,
“版本”:“1.0.0”,
"description": "单元测试艺术代码示例第三版",
“主要”:“index.js”,
“脚本”:{
“测试”:“节点./ch1-basics/custom-test-phase1.js”,
}
}{
"name": "aout3-samples",
"version": "1.0.0",
"description": "Code Samples for Art of Unit Testing 3rd Edition",
"main": "index.js",
"scripts": {
"test": "node ./ch1-basics/custom-test-phase1.js",
}
}
测试方法调用生产模块(SUT),然后检查返回值。如果不是预期的结果,测试方法会向控制台写入错误和堆栈跟踪。测试方法还会捕获发生的任何异常并将其写入控制台,以便它们不会干扰后续方法的运行。当我们使用测试框架时,通常会自动为我们处理。
The test method invokes the production module (the SUT) and then checks the returned value. If it’s not what’s expected, the test method writes to the console an error and a stack trace. The test method also catches any exceptions that occur and writes them to the console, so that they don’t interfere with the running of subsequent methods. When we use a test framework, that’s usually handled for us automatically.
显然,这是编写此类测试的临时方法。如果您要编写这样的多个测试,您可能希望有一个所有测试都可以使用的通用test或check方法,并且它可以一致地格式化错误。您还可以添加特殊的帮助器方法来检查空对象、空字符串等内容,这样您就不需要在许多测试中编写相同的长行代码。
Obviously, this is an ad hoc way of writing such a test. If you were to write multiple tests like this, you might want to have a generic test or check method that all tests could use, and which would format the errors consistently. You could also add special helper methods that would check on things like null objects, empty strings, and so on, so that you don’t need to write the same long lines of code in many tests.
以下清单显示了此测试的外观,具有稍微更通用的check功能assertEquals。
The following listing shows what this test would look like with a slightly more generic check and assertEquals functions.
Listing 1.6 Using a more generic implementation of the Check method
const assertEquals = (预期, 实际) => {
if (实际!==预期) {
throw new Error(`预期为 ${expected},但实际为 ${actual}`);
}
};
const check = (名称, 实现) => {
尝试 {
执行();
console.log(`${name} 已通过`);
} 捕获 (e) {
console.error(`${name} 失败`, e.stack);
}
};
check('用 2 个数字求和应该将它们相加', () => {
const result = sum('1,2');
assertEquals(3, result);
});
check('多位数求和应该将它们相加', () => {
const result = sum('10,20');
assertEquals(30, result);
});const assertEquals = (expected, actual) => {
if (actual !== expected) {
throw new Error(`Expected ${expected} but was ${actual}`);
}
};
const check = (name, implementation) => {
try {
implementation();
console.log(`${name} passed`);
} catch (e) {
console.error(`${name} FAILED`, e.stack);
}
};
check('sum with 2 numbers should sum them up', () => {
const result = sum('1,2');
assertEquals(3, result);
});
check('sum with multiple digit numbers should sum them up', () => {
const result = sum('10,20');
assertEquals(30, result);
});
我们现在创建了两个辅助方法:assertEquals,它删除了用于写入控制台或引发错误的样板代码,以及check,它采用一个字符串作为测试名称和对实现的回调。然后,它负责捕获任何测试错误,将它们写入控制台,并报告测试的状态。
We’ve now created two helper methods: assertEquals, which removes boilerplate code for writing to the console or throwing errors, and check, which takes a string for the name of the test and a callback to the implementation. It then takes care of catching any test errors, writing them to the console, and reporting on the status of the test.
请注意,仅使用几个辅助方法,测试就变得更易于阅读和更快地编写。Jest 等单元测试框架可以提供更通用的帮助方法,因此测试更容易编写。我将在第 2 章中讨论这一点。首先,让我们谈谈本书的主题:良好的单元测试。
Notice how the tests are easier to read and faster to write with just a couple of helper methods. Unit testing frameworks such as Jest can provide even more generic helper methods like this, so tests are even easier to write. I’ll talk about that in chapter 2. First, let’s talk a bit about the main subject of this book: good unit tests.
无论您使用哪种编程语言,定义单元测试最困难的方面之一就是定义好的单元测试的含义。当然,好是相对的,每当我们学习有关编码的新知识时,它就会发生变化。这似乎是显而易见的,但事实并非如此。我需要解释为什么我们需要编写更好的测试——理解工作单元是什么是不够的。
No matter what programming language you’re using, one of the most difficult aspects of defining a unit test is defining what’s meant by a good one. Of course, good is relative, and it can change whenever we learn something new about coding. That may seem obvious, but it really isn’t. I need to explain why we need to write better tests—understanding what a unit of work is isn’t enough.
根据我自己的经验,多年来涉及许多公司和团队,大多数尝试对其代码进行单元测试的人要么在某个时候放弃,要么实际上不执行单元测试。他们浪费大量时间编写有问题的测试,当必须花费大量时间维护测试时,他们就会放弃,或者更糟糕的是,他们不相信自己的结果。
Based on my own experience, involving many companies and teams over the years, most people who try to unit test their code either give up at some point or don’t actually perform unit tests. They waste a lot of time writing problematic tests, and they give up when they have to spend a lot of time maintaining them, or worse, they don’t trust their results.
编写一个糟糕的单元测试是没有意义的,除非您正在学习如何编写一个好的单元测试。编写糟糕的测试弊大于利,例如浪费时间调试有缺陷的测试,浪费时间编写没有任何好处的测试,浪费时间试图理解不可读的测试,以及浪费时间编写测试几个月后才删除它们。维护不良测试以及它们如何影响生产代码的可维护性也是一个巨大的问题。糟糕的测试实际上会降低您的开发速度,不仅在编写测试代码时如此,在编写生产代码时也是如此。我将在本书后面讨论所有这些内容。
There’s no point in writing a bad unit test, unless you’re in the process of learning how to write a good one. There are more downsides than upsides to writing bad tests, such as wasting time debugging buggy tests, wasting time writing tests that bring no benefit, wasting time trying to understand unreadable tests, and wasting time writing tests only to delete them a few months later. There’s also a huge issue with maintaining bad tests, and with how they affect the maintainability of production code. Bad tests can actually slow down your development speed, not only when writing test code, but also when writing production code. I’ll touch on all these things later in the book.
通过了解什么是好的单元测试,您可以确定您不会走上一条以后难以修复的道路,那时代码会变成一场噩梦。我们还将在本书后面定义其他形式的测试(组件、端到端等)。
By learning what a good unit test is, you can be sure you aren’t starting off on a path that will be hard to fix later on, when the code becomes a nightmare. We’ll also define other forms of tests (component, end to end, and more) later in the book.
Every good automated test (not just unit tests) should have the following properties:
It should be easy to understand the intent of the test author.
It should be consistent in its results (it should always return the same result if you don’t change anything between runs).
When it fails, it should be easy to detect what was expected and determine how to pinpoint the problem.
A good unit test should also exhibit the following properties:
It should have full control of the code under test (more on that in chapter 3).
It should be fully isolated (run independently of other tests).
It should run in memory without requiring system files, networks, or databases.
It should be as synchronous and linear as possible when that makes sense (no parallel threads if we can help it).
不可能所有测试都遵循良好单元测试的属性,但这没关系。此类测试将简单地过渡到集成测试领域(第 1.8 节的主题)。尽管如此,还是有一些方法可以重构一些测试以符合这些属性。
It’s impossible for all tests to follow the properties of a good unit test, and that’s fine. Such tests will simply transition to the realm of integration testing (the topic of section 1.8). Still, there are ways to refactor some of your tests to conform to these properties.
Replacing the database (or another dependency) with a stub
我们将在后面的章节中讨论桩,但简而言之,它们是模拟真实依赖项的虚假依赖项。它们的目的是简化测试过程,因为它们更容易设置和维护。
We’ll discuss stubs in later chapters, but, in short, they are fake dependencies that emulate the real ones. Their purpose is to simplify the process of testing because they are easier to set up and maintain.
但要小心内存数据库。它们可以帮助您将测试彼此隔离(只要您不在测试之间共享数据库实例),从而遵守良好单元测试的属性,但此类数据库会导致尴尬的中间位置。内存数据库不像桩那么容易设置。同时,它们不提供像真实数据库那样强有力的保证。从功能角度来看,内存数据库可能与生产数据库有很大不同,因此通过内存数据库的测试可能会失败,反之亦然。您通常必须针对生产数据库手动重新运行相同的测试,以获得代码工作的额外信心。除非您使用一小部分标准化的 SQL 功能,否则我建议坚持使用桩(用于单元测试)或真实数据库(用于集成测试)。
Beware of in-memory databases, though. They can help you isolate tests from each other (as long as you don’t share database instances between tests) and thus adhere to the properties of good unit tests, but such databases lead to an awkward, in-between spot. In-memory databases aren’t as easy to set up as stubs. At the same time, they don’t provide as strong guarantees as real databases. Functionality-wise, an in-memory database may differ drastically from the production one, so tests that pass an in-memory database may fail the real one, and vice versa. You’ll often have to rerun the same tests manually against the production database to gain additional confidence that your code works. Unless you use a small and standardized set of SQL features, I recommend sticking to either stubs (for unit tests) or real databases (for integration testing).
对于 jsdom 这样的解决方案也是如此。您可以使用它来替换真实的 DOM,但请确保它支持您的特定用例。不要编写需要您手动重新检查的测试。
The same is true for solutions like jsdom. You can use it to replace the real DOM, but make sure it supports your particular use cases. Don’t write tests that require you to manually recheck them.
Emulating asynchronous processing with linear, synchronous tests
随着 Promise 和 的出现async/await,异步编码已成为 JavaScript 中的标准。不过,我们的测试仍然可以同步验证异步代码。通常,这意味着直接从测试触发回调或显式等待异步操作完成执行。
With the advent of promises and async/await, asynchronous coding has become standard in JavaScript. Our tests can still verify asynchronous code synchronously, though. Usually that means triggering callbacks directly from the test or explicitly waiting for an asynchronous operation to finish executing.
许多人将测试软件的行为与单元测试的概念混淆了。首先,问自己以下有关您迄今为止编写和执行的测试的问题:
Many people confuse the act of testing their software with the concept of a unit test. To start off, ask yourself the following questions about the tests you’ve written and executed up to now:
Can I run and get results from a test I wrote two weeks or months or years ago?
Can any member of my team run and get results from tests I wrote two months ago?
Can I run all the tests I’ve written in no more than a few minutes?
Can I run all the tests I’ve written at the push of a button?
Do my tests pass when there are bugs in another team’s code?
Do my tests show the same results when run on different machines or environments?
Do my tests stop working if there’s no database, network, or deployment?
If I delete, move, or change one test, do other tests remain unaffected?
如果您对这些问题中的任何一个回答“否”,则您所实现的内容很可能不是完全自动化的,或者不是单元测试。这绝对是某种测试,它可能与单元测试一样重要,但与让您对所有这些问题回答“是”的测试相比,它有缺点。
If you answered “no” to any of these questions, there’s a high probability that what you’re implementing either isn’t fully automated or it isn’t a unit test. It’s definitely some kind of test, and it might be as important as a unit test, but it has drawbacks compared to tests that would let you answer yes to all of those questions.
“到现在为止我在做什么?” 你可能会问。您一直在进行集成测试。
“What was I doing until now?” you might ask. You’ve been doing integration testing.
我认为集成测试是任何不符合前面概述的良好单元测试的一个或多个条件的测试。例如,如果测试使用真实的网络、真实的 REST API、真实的系统时间、真实的文件系统或真实的数据库,那么它就进入了集成测试的领域。
I consider integration tests to be any tests that don’t live up to one or more of the conditions outlined previously for good unit tests. For example, if the test uses the real network, the real rest APIs, the real system time, the real filesystem, or a real database, it has stepped into the realm of integration testing.
例如,如果测试无法控制系统时间,并且它使用new Date()测试代码中的当前时间,则每次执行测试时,它本质上都是不同的测试,因为它使用不同的时间。它不再一致。这本身并不是一件坏事。我认为集成测试是单元测试的重要对应部分,但它们应该与单元测试分开,以达到一种“安全的绿色区域”的感觉,这将在本书后面讨论。
If a test doesn’t have control of the system time, for example, and it uses the current new Date() in the test code, then every time the test executes, it’s essentially a different test because it uses a different time. It’s no longer consistent. That’s not a bad thing per se. I think integration tests are important counterparts to unit tests, but they should be separated from them to achieve a feeling of “safe green zone,” which is discussed later in this book.
如果测试使用真实数据库,它就不再只在内存中运行,它的操作比仅使用内存中的假数据更难擦除。测试也将运行更长的时间,并且我们将无法轻松控制数据访问所需的时间。单元测试应该很快。集成测试通常要慢得多。当您开始进行数百次测试时,每半秒都很重要。
If a test uses the real database, it’s no longer only running in memory—its actions are harder to erase than when using only in-memory fake data. The test will also run longer, and we won’t easily be able to control how long data access takes. Unit tests should be fast. Integration tests are usually much slower. When you start having hundreds of tests, every half-second counts.
集成测试增加了另一个问题的风险:同时测试太多东西。例如,假设你的车坏了。您如何了解问题所在,更不用说解决问题了?引擎由许多协同工作的子系统组成,每个子系统都依赖其他子系统来帮助产生最终结果:一辆移动的汽车。如果汽车停止行驶,则故障可能出在任何一个子系统上,或者多个子系统上。正是这些子系统(或层)的集成使汽车得以移动。您可以将汽车的运动视为汽车在路上行驶时这些部件的最终集成测试。如果测试失败,所有部分都会一起失败;如果成功,所有部分都会成功。
Integration tests increase the risk of another problem: testing too many things at once. For example, suppose your car breaks down. How do you learn what the problem is, let alone fix it? An engine consists of many subsystems working together, each relying on the others to help produce the final result: a moving car. If the car stops moving, the fault could be with any of the subsystems—or with more than one. It’s the integration of those subsystems (or layers) that makes the car move. You could think of the car’s movement as the ultimate integration test of these parts as the car goes down the road. If the test fails, all the parts fail together; if it succeeds, all the parts succeed.
同样的事情也发生在软件中。大多数开发人员测试其功能的方式是通过应用程序或 REST API 或 UI 的最终功能。单击某个按钮会触发一系列事件——函数、模块和组件一起工作以产生最终结果。如果测试失败,所有这些软件组件都会作为一个整体失败,并且很难找出导致整体操作失败的原因(见图 1.7)。
The same thing happens in software. The way most developers test their functionality is through the final functionality of the app or REST API or UI. Clicking some button triggers a series of events—functions, modules, and components working together to produce the final result. If the test fails, all of these software components fail as a team, and it can be difficult to figure out what caused the failure of the overall operation (see figure 1.7).
图 1.7 在集成测试中可能会有很多失败点。所有单元都必须协同工作,每个单元都可能发生故障,从而使查找错误来源变得更加困难。
Figure 1.7 You can have many failure points in an integration test. All the units have to work together, and each could malfunction, making it harder to find the source of a bug.
正如Bill Hetzel 的《软件测试完整指南》(Wiley,1988)中所定义的,集成测试是“一个有序的测试过程,其中软件和/或硬件元素被组合和测试,直到整个系统被集成。” 这是我自己定义集成测试的变体:
As defined in The Complete Guide to Software Testing by Bill Hetzel (Wiley, 1988), integration testing is “an orderly progression of testing in which software and/or hardware elements are combined and tested until the entire system has been integrated.” Here’s my own variation on defining integration testing:
集成测试是在不完全控制其所有实际依赖项的情况下测试一个工作单元,例如其他团队的其他组件、其他服务、时间、网络、数据库、线程、随机数生成器等。
Integration testing is testing a unit of work without having full control over all of its real dependencies, such as other components by other teams, other services, the time, the network, databases, threads, random number generators, and more.
总而言之,集成测试使用真实的依赖关系;单元测试将工作单元与其依赖关系隔离开来,以便它们的结果很容易保持一致,并且可以轻松控制和模拟单元行为的任何方面。
To summarize, an integration test uses real dependencies; unit tests isolate the unit of work from its dependencies so that they’re easily consistent in their results and can easily control and simulate any aspect of the unit’s behavior.
让我们将 1.7.2 节中的问题应用到集成测试中,并考虑您希望通过实际单元测试实现什么目标:
Let’s apply the questions from section 1.7.2 to integration tests and consider what you want to achieve with real-world unit tests:
我可以运行两周前、几个月前或几年前编写的测试并获得结果吗?
如果不能,您如何知道您是否破坏了之前创建的功能?共享数据和代码在应用程序的生命周期中定期更改,如果您在更改代码后无法(或不会)对所有以前工作的功能运行测试,您可能会在不知情的情况下破坏它 - 这称为回归。 当开发人员面临修复现有错误的压力时,在冲刺或发布即将结束时,回归似乎经常发生。有时,他们在解决旧错误时会无意中引入新错误。知道自己在破坏某个东西后的 60 秒内就破坏了它,这不是很棒吗?您将在本书后面看到如何做到这一点。
Can I run and get results from a test I wrote two weeks or months or years ago?
If you can’t, how would you know whether you broke a feature that you created earlier? Shared data and code changes regularly during the life of an application, and if you can’t (or won’t) run tests for all the previously working features after changing your code, you just might break it without knowing—this is known as a regression. Regressions seem to occur a lot near the end of a sprint or release, when developers are under pressure to fix existing bugs. Sometimes they introduce new bugs inadvertently as they resolve old ones. Wouldn’t it be great to know that you broke something within 60 seconds of breaking it? You’ll see how that can be done later in this book.
定义回归是指功能被破坏——曾经可以工作的代码。您还可以将其视为曾经有效但现在无效的一个或多个工作单元。
Definition A regression is broken functionality—code that used to work. You can also think of it as one or more units of work that once worked and now don’t.
我的团队中的任何成员都可以运行我两个月前编写的测试并获得结果吗?
这与上一点相一致,但又更上一层楼。您需要确保在更改某些内容时不会破坏其他人的代码。许多开发人员担心更改旧系统中的遗留代码,因为担心不知道其他代码取决于他们正在更改的内容。从本质上讲,他们冒着将系统转变为未知稳定状态的风险。
没有什么比不知道应用程序是否仍然有效更可怕的了,尤其是当您没有编写该代码时。如果您拥有单元测试的安全网并且知道您没有破坏任何东西,那么您就不会那么害怕接受您不太熟悉的代码。
任何人都可以访问和运行好的测试。
Can any member of my team run and get results from tests I wrote two months ago?
This goes with the previous point but takes it up a notch. You want to make sure that you don’t break someone else’s code when you change something. Many developers fear changing legacy code in older systems for fear of not knowing what other code depends on what they’re changing. In essence, they risk changing the system into an unknown state of stability.
Few things are scarier than not knowing whether the application still works, especially when you didn’t write that code. If you have that safety net of unit tests and know you aren’t breaking anything, you’ll be much less afraid of taking on code you’re less familiar with.
Good tests can be accessed and run by anyone.
定义 遗留代码被维基百科定义为“标准硬件和环境不再支持的旧计算机源代码”(https://en.wikipedia.org/wiki/Legacy_system),但许多商店指的是任何旧版本的该应用程序当前正在作为遗留代码进行维护。它通常指的是难以使用、难以测试、甚至通常难以阅读的代码。一位客户曾经以脚踏实地的方式定义遗留代码:“有效的代码”。许多人喜欢将遗留代码定义为“没有测试的代码”。Michael Feathers 的《有效处理遗留代码》(Pearson,2004 年)使用“没有测试的代码”作为遗留代码的官方定义,这是阅读本书时需要考虑的定义。
Definition Legacy code is defined by Wikipedia as “old computer source code that is no longer supported on the standard hardware and environments” (https://en.wikipedia.org/wiki/Legacy_system), but many shops refer to any older version of the application currently under maintenance as legacy code. It often refers to code that’s hard to work with, hard to test, and usually even hard to read. A client once defined legacy code in a down-to-earth way: “code that works.” Many people like to define legacy code as “code that has no tests.” Working Effectively with Legacy Code by Michael Feathers (Pearson, 2004) uses “code that has no tests” as an official definition of legacy code, and it’s a definition to be considered while reading this book.
如果您无法快速运行测试(几秒钟比几分钟更好),您将减少运行测试的频率(每天,甚至在某些地方每周或每月)。问题是,当你更改代码时,你希望尽早获得反馈,看看你是否破坏了某些东西。运行测试之间所需的时间越长,对系统所做的更改就越多,并且当您发现破坏了某些内容时,您必须在(许多)更多地方搜索错误。
好的测试应该运行得很快。
Can I run all the tests I’ve written in no more than a few minutes?
If you can’t run your tests quickly (seconds are better than minutes), you’ll run them less often (daily, or even weekly or monthly in some places). The problem is that when you change code, you want to get feedback as early as possible to see if you broke something. The more time required between running the tests, the more changes you make to the system, and the (many) more places you’ll have to search for bugs when you find that you broke something.
Good tests should run quickly.
我可以通过按一下按钮来运行我编写的所有测试吗?
如果不能,则可能意味着您必须配置将运行测试的计算机,以便它们正确运行(例如,设置 Docker 环境,或设置数据库的连接字符串),或者可能意味着您的单元测试不是完全自动化的。如果您无法完全自动化单元测试,您可能会避免重复运行它们,团队中的其他人也是如此。
没有人喜欢陷入配置细节来运行测试,只是为了确保系统仍然正常工作。开发人员有更重要的事情要做,比如向系统写入更多功能。但如果他们不知道系统的状态,他们就无法做到这一点。
好的测试应该能够以原始形式轻松执行,而不是手动执行。
Can I run all the tests I’ve written at the push of a button?
If you can’t, it probably means that you have to configure the machine on which the tests will run so that they run correctly (setting up a Docker environment, or setting connection strings to the database, for example), or it may mean that your unit tests aren’t fully automated. If you can’t fully automate your unit tests, you’ll probably avoid running them repeatedly, as will everyone else on your team.
No one likes to get bogged down with configuring details to run tests, just to make sure that the system still works. Developers have more important things to do, like writing more features into the system. But they can’t do that if they don’t know the state of the system.
Good tests should be easily executed in their original form, not manually.
我可以在几分钟之内编写一个基本测试吗?
发现集成测试的最简单方法之一是正确准备和实施需要时间,而不仅仅是执行。由于所有内部依赖项(有时是外部依赖项),需要花一些时间来弄清楚如何编写它。(数据库可能被视为外部依赖项。)如果您没有自动化测试,依赖项就不是什么问题,但您将失去自动化测试的所有好处。编写测试越困难,您编写更多测试或关注除您担心的“大事”之外的任何事情的可能性就越小。单元测试的优点之一是它们倾向于测试每一个可能损坏的小东西,而不仅仅是大东西。人们常常惊讶地发现,在他们认为简单且没有错误的代码中竟然发现了如此之多的错误。
当您只专注于大型测试时,对代码的整体信心仍然非常缺乏。代码的核心逻辑的许多部分都没有经过测试(即使您可能覆盖了更多组件),并且可能存在许多您没有考虑过并且可能“非正式”担心的错误。
一旦您弄清楚了要用来测试一组特定的对象、函数和依赖项(域模型)的模式,针对系统的良好测试应该可以轻松快速地编写。
Can I write a basic test in no more than a few minutes?
One of the easiest ways to spot an integration test is that it takes time to prepare correctly and to implement, not just to execute. It takes time to figure out how to write it because of all the internal, and sometimes external, dependencies. (A database may be considered an external dependency.) If you’re not automating the test, dependencies are less of a problem, but you’re losing all the benefits of an automated test. The harder it is to write a test, the less likely you are to write more tests or to focus on anything other than the “big stuff” that you’re worried about. One of the strengths of unit tests is that they tend to test every little thing that might break, not only the big stuff. People are often surprised at how many bugs they can find in code they thought was simple and bug free.
When you concentrate only on the big tests, the overall confidence in your code is still very much lacking. Many parts of the code’s core logic aren’t tested (even though you may be covering more components), and there may be many bugs that you haven’t considered and might be “unofficially” worried about.
Good tests against the system should be easy and quick to write, once you’ve figured out the patterns you want to use to test your specific set of objects, functions, and dependencies (the domain model).
当其他团队的代码中存在错误时,我的测试能否通过?在不同的机器或环境上运行时,我的测试是否显示相同的结果?如果没有数据库、网络或部署,我的测试会停止工作吗?
这三点指的是我们的测试代码与各种依赖项隔离的想法。测试结果是一致的,因为我们可以控制系统的间接输入提供的内容。我们可以拥有虚假的数据库、虚假的网络、虚假的时间和虚假的机器文化。在后面的章节中,我将把这些点称为桩和接缝,我们可以在其中注入这些桩。
Do my tests pass when there are bugs in another team’s code? Do my tests show the same results when run on different machines or environments? Do my tests stop working if there’s no database, network, or deployment?
These three points refer to the idea that our test code is isolated from various dependencies. The test results are consistent because we have control over what those indirect inputs into our system provide. We can have fake databases, fake networks, fake time, and fake machine culture. In later chapters, I’ll refer to those points as stubs and seams in which we can inject those stubs.
如果我删除、移动或更改一项测试,其他测试是否不受影响?
单元测试通常不需要任何共享状态,但集成测试通常需要,例如外部数据库或服务。共享状态可以在测试之间创建依赖关系。例如,以错误的顺序运行测试可能会破坏未来测试的状态。
If I delete, move, or change one test, do other tests remain unaffected?
Unit tests usually don’t need to have any shared state, but integration tests often do, such as an external database or service. Shared state can create a dependency between tests. For example, running tests in the wrong order can corrupt the state for future tests.
警告即使是经验丰富的单元测试人员也会发现,可能需要 30 分钟或更长时间才能弄清楚如何针对他们以前从未进行过单元测试的域模型编写第一个单元测试。这是工作的一部分,也是意料之中的。一旦您弄清楚了工作单元的入口点和出口点,对该域模型的第二次和后续测试应该很容易完成。
WARNING Even experienced unit testers can find that it may take 30 minutes or more to figure out how to write the very first unit test against a domain model they’ve never unit tested before. This is part of the work and is to be expected. The second and subsequent tests on that domain model should be very easy to accomplish once you’ve figured out the entry and exit points of the unit of work.
We can recognize three main criteria in the previous questions and answers:
Readability—If we can’t read it, then it’s hard to maintain, hard to debug, and hard to know what’s wrong.
Maintainability—If maintaining the test or production code is painful because of the tests, our lives will become a living nightmare.
信任——如果我们在测试失败时不相信测试结果,我们将再次开始手动测试,从而失去测试应提供的所有好处。如果我们在测试通过时不信任它们,我们将开始更多调试,再次失去任何时间优势。
Trust—If we don’t trust the results of our tests when they fail, we’ll start manually testing again, losing all the time benefit the tests are supposed to provide. If we don’t trust the tests when they pass, we’ll start debugging more, again losing any time benefit.
到目前为止,我已经解释了单元测试不是什么以及需要提供哪些功能才能使测试有用,现在我可以开始回答本章提出的主要问题:什么是好的单元测试?
From what I’ve explained so far about what a unit test is not and what features need to be present for testing to be useful, I can now start to answer the primary question this chapter poses: what is a good unit test?
现在我已经介绍了单元测试应具有的重要属性,我将一劳永逸地定义单元测试:
Now that I’ve covered the important properties that a unit test should have, I’ll define unit tests once and for all:
单元测试是一段自动化的代码,它通过入口点调用工作单元,然后检查其出口点之一。单元测试几乎总是使用单元测试框架编写。它可以很容易地编写并且运行得很快。它是值得信赖、可读且可维护的。只要我们控制的生产代码没有改变,它就是一致的。
A unit test is an automated piece of code that invokes the unit of work through an entry point and then checks one of its exit points. A unit test is almost always written using a unit testing framework. It can be written easily and runs quickly. It’s trustworthy, readable, and maintainable. It is consistent as long as the production code we control has not changed.
这个定义看起来确实是一个艰巨的任务,特别是考虑到有多少开发人员执行单元测试很差。它让我们认真审视我们作为开发人员迄今为止实现测试的方式,与我们想要的实现方式进行比较。(第 7 章到第 9 章深入讨论了可信、可读和可维护的测试。)
This definition certainly looks like a tall order, particularly considering how many developers implement unit tests poorly. It makes us take a hard look at the way we, as developers, have implemented testing up until now, compared to how we’d like to implement it. (Trustworthy, readable, and maintainable tests are discussed in depth in chapters 7 through 9.)
在本书的第一版中,我对单元测试的定义略有不同。我曾经将单元测试定义为“仅针对控制流代码运行”,但我不再认为这是真的。没有逻辑的代码通常用作工作单元的一部分。即使没有逻辑的属性也会被工作单元使用,因此测试不必专门针对它们。
In the first edition of this book, my definition of a unit test was slightly different. I used to define a unit test as “only running against control flow code,” but I no longer think that’s true. Code without logic is usually used as part of a unit of work. Even properties with no logic will get used by a unit of work, so they don’t have to be specifically targeted by tests.
定义 控制流代码是任何包含某种逻辑的代码,无论它有多小。它具有以下一项或多项:if语句、循环、计算或任何其他类型的决策代码。
Definition Control flow code is any piece of code that has some sort of logic in it, small as it may be. It has one or more of the following: an if statement, a loop, calculations, or any other type of decision-making code.
Getter 和 Setter 是代码的良好示例,通常不包含任何逻辑,因此不需要测试的特定目标。您正在测试的工作单元可能会使用它的代码,但无需直接测试它。但请注意:一旦在 getter 或 setter 中添加任何逻辑,您将需要确保逻辑正在被测试。
Getters and setters are good examples of code that usually doesn’t contain any logic and so don’t require specific targeting by the tests. It’s code that will probably get used by the unit of work you’re testing, but there’s no need to test it directly. But watch out: once you add any logic inside a getter or setter, you’ll want to make sure that logic is being tested.
在下一节中,我们将不再讨论什么是好的测试,而是讨论何时需要编写测试。我将讨论测试驱动开发,因为它通常与单元测试放在同一位置。我想确保我们澄清这一点。
In the next section, we’ll stop talking about what is a good test and talk about when you might want to write tests. I’ll discuss test-driven development, because it is often put in the same bucket as doing unit testing. I want to make sure we set the record straight on that.
一旦您知道如何使用单元测试框架编写可读、可维护且值得信赖的测试,下一个问题就是何时编写测试。许多人认为为软件编写单元测试的最佳时间是在创建一些功能之后并将代码合并到远程源代码管理之前。
Once you know how to write readable, maintainable, and trustworthy tests with a unit testing framework, the next question is when to write the tests. Many people feel that the best time to write unit tests for software is after they’ve created some functionality and just before they merge their code into remote source control.
另外,坦白地说,很多人不认为编写测试是一个好主意,但通过反复试验意识到源代码控制审查中有严格的测试要求,因此他们必须编写测试来安抚代码审查上帝并将他们的代码合并到主分支中。(这种动态是不良测试的重要来源,我将在本书的第三部分中解决它。)
Also, to be a bit blunt, a lot of people don’t believe writing tests is a good idea, but have realized through trial and error that there are strict testing requirements in source control reviews, so they have to write tests to appease the code review gods and get their code merged into the main branch. (That kind of dynamic is a great source of bad tests, and I’ll address it in the third part of this book.)
越来越多的开发人员更喜欢在编码会话期间以及实现每个非常小的功能之前增量编写单元测试。这种方法称为测试优先或测试驱动开发 (TDD)。
A growing number of developers prefer writing unit tests incrementally, during the coding session and before each piece of very small functionality is implemented. This approach is called test-first or test-driven development (TDD).
注意对于测试驱动开发的确切含义有许多不同的观点。有人说这是测试优先的开发,有人说这意味着你有很多测试。有人说这是一种设计方式,而另一些人则认为这可能是一种仅通过一些设计即可驱动代码行为的方式。在本书中,TDD 意味着测试优先开发,设计在技术中扮演增量角色(除了本节之外,本书不会讨论 TDD)。
Note There are many different views on exactly what test-driven development means. Some say it’s test-first development, and some say it means you have a lot of tests. Some say it’s a way of designing, and others feel it could be a way to drive your code’s behavior with only some design. In this book, TDD means test-first development, with design taking an incremental role in the technique (besides this section, TDD won’t be discussed in this book).
图 1.8 和图 1.9 显示了传统编码和 TDD 之间的差异。TDD与传统开发不同,如图1.9所示。您首先编写一个失败的测试;然后您继续创建生产代码,查看测试通过,并继续重构代码或创建另一个失败的测试。
Figures 1.8 and 1.9 show the differences between traditional coding and TDD. TDD is different from traditional development, as figure 1.9 shows. You begin by writing a test that fails; then you move on to creating the production code, seeing the test pass, and continuing on to either refactor your code or create another failing test.
Figure 1.8 The traditional way of writing unit tests
图 1.9 测试驱动开发——鸟瞰图。请注意该过程的循环性质:编写测试、编写代码、重构、编写下一个测试。它显示了 TDD 的增量性质:小步骤可以充满信心地带来高质量的最终结果。
Figure 1.9 Test-driven development—a bird’s-eye view. Notice the circular nature of the process: write the test, write the code, refactor, write the next test. It shows the incremental nature of TDD: small steps lead to a quality end result with confidence.
本书重点关注编写良好单元测试的技术,而不是 TDD,但我是 TDD 的忠实粉丝。我使用 TDD 编写了多个主要应用程序和框架,管理过使用它的团队,并且教授了数百门有关 TDD 和单元测试技术的课程和研讨会。在我的整个职业生涯中,我发现 TDD 对于创建高质量代码、质量测试以及为我所编写的代码提供更好的设计很有帮助。我相信它可以为您带来好处,但它并不是没有代价的(学习时间、实施时间等等)。不过,如果您愿意接受学习的挑战,那么它绝对物有所值。
This book focuses on the technique of writing good unit tests, rather than on TDD, but I’m a big fan of TDD. I’ve written several major applications and frameworks using TDD, I’ve managed teams that utilize it, and I’ve taught hundreds of courses and workshops on TDD and unit testing techniques. Throughout my career, I’ve found TDD to be helpful in creating quality code, quality tests, and better designs for the code I was writing. I’m convinced that it can work to your benefit, but it’s not without a price (time to learn, time to implement, and more). It’s definitely worth the admission price, though, if you’re willing to take on the challenge of learning it.
重要的是要认识到 TDD 并不能确保项目成功或测试稳健或可维护。人们很容易陷入 TDD 技术而不注意单元测试的编写方式:它们的命名、它们的可维护性或可读性,以及它们是否测试了正确的东西或者它们本身可能存在错误。这就是我写这本书的原因——因为编写好的测试是一项独立于 TDD 的技能。
It’s important to realize that TDD doesn’t ensure project success or tests that are robust or maintainable. It’s quite easy to get caught up in the technique of TDD and not pay attention to the way unit tests are written: their naming, how maintainable or readable they are, and whether they test the right things or might themselves have bugs. That’s why I’m writing this book—because writing good tests is a separate skill from TDD.
The technique of TDD is quite simple:
编写失败的测试来证明最终产品中缺少代码或功能。测试的编写方式就好像生产代码已经可以工作一样,因此测试失败意味着生产代码中存在错误。我怎么知道?测试的编写方式是,如果生产代码没有错误,测试就会通过。
在 JavaScript 以外的某些语言中,测试一开始甚至可能无法编译,因为代码尚不存在。一旦运行,它应该会失败,因为生产代码仍然无法工作。这就是测试驱动设计思维中的许多“设计”发生的地方。
Write a failing test to prove code or functionality is missing from the end product. The test is written as if the production code were already working, so the test failing means there’s a bug in the production code. How do I know? The test is written such that it would pass if the production code had no bugs.
In some languages other than JavaScript, the test might not even compile at first, since the code doesn’t exist yet. Once it does run, it should be failing, because the production code is still not working. This is where a lot of the “design” in test-driven-design thinking happens.
通过向生产代码添加满足测试期望的功能来使测试通过。生产代码应尽可能简单。不要碰测试。您必须仅通过触摸生产代码来使其通过。
Make the test pass by adding functionality to the production code that meets the expectations of your test. The production code should be kept as simple as possible. Don’t touch the test. You have to make it pass only by touching production code.
重构你的代码。当测试通过时,您可以自由地继续进行下一个单元测试或重构代码(生产代码和测试)以使其更具可读性,消除代码重复等。这是“设计”部分发生的另一个点。我们重构甚至可以重新设计我们的组件,同时仍然保留旧功能。
重构步骤应该非常小并且是增量的,并且我们在每个小步骤之后运行所有测试,以确保我们的更改不会破坏任何内容。重构可以在编写多个测试后或编写每个测试后进行。这是一个重要的实践,因为它可以确保您的代码更易于阅读和维护,同时仍然通过所有以前编写的测试。本书后面有一整节(8.3)是关于重构的。
Refactor your code. When the test passes, you’re free to move on to the next unit test or to refactor your code (both production code and tests) to make it more readable, to remove code duplication, and so on. This is another point where the “design” part happens. We refactor and can even redesign our components while still keeping the old functionality.
Refactoring steps should be very small and incremental, and we run all the tests after each small step to make sure we didn’t break anything with our changes. Refactoring can be done after writing several tests or after writing each test. It’s an important practice, because it ensures your code gets easier to read and maintain, while still passing all of the previously written tests. There’s a whole section (8.3) on refactoring later in the book.
定义 重构意味着改变一段代码而不改变其功能。如果您曾经重命名过一个方法,那么您就已经完成了重构。如果您曾经将一个大型方法拆分为多个较小的方法调用,那么您就已经重构了您的代码。代码仍然做同样的事情,但它变得更容易维护、阅读、调试和更改。
Definition Refactoring means changing a piece of code without changing its functionality. If you’ve ever renamed a method, you’ve done refactoring. If you’ve ever split a large method into multiple smaller method calls, you’ve refactored your code. The code still does the same thing, but it becomes easier to maintain, read, debug, and change.
前面的步骤听起来很技术性,但背后蕴藏着很多智慧。如果做得正确,TDD 可以使您的代码质量飙升,减少错误数量,提高您对代码的信心,缩短发现错误所需的时间,改进代码的设计,并使您的经理更高兴。如果 TDD 做得不正确,可能会导致项目进度延误、浪费时间、降低积极性并降低代码质量。这是一把双刃剑,很多人都经历了一番艰难才发现这一点。
The preceding steps sound technical, but there’s a lot of wisdom behind them. Done correctly, TDD can make your code quality soar, decrease the number of bugs, raise your confidence in the code, shorten the time it takes to find bugs, improve your code’s design, and keep your manager happier. If TDD is done incorrectly, it can cause your project schedule to slip, waste your time, lower your motivation, and lower your code quality. It’s a double-edged sword, and many people find this out the hard way.
从技术上讲,没有人告诉您的 TDD 最大好处之一是,通过看到测试失败,然后看到它在不更改测试的情况下通过,您基本上是在测试测试本身。如果您预计它会失败而它却通过了,那么您的测试中可能存在错误,或者您正在测试错误的东西。如果测试失败,你修复了它,现在你期望它通过,但它仍然失败,你的测试可能有错误,或者可能期望发生错误的事情。
Technically, one of the biggest benefits of TDD that nobody tells you about is that by seeing a test fail, and then seeing it pass without changing the test, you’re basically testing the test itself. If you expect it to fail and it passes, you might have a bug in your test or you’re testing the wrong thing. If the test failed, you fixed it, and now you expect it to pass, and it still fails, your test could have a bug, or maybe it’s expecting the wrong thing to happen.
本书讨论的是可读的、可维护的和值得信赖的测试,但是如果你在上面添加 TDD,你对自己的测试的信心将会增加,因为你会看到失败的地方,你修复了它,测试在应该失败的时候失败,在应该通过的时候通过。在后测试风格中,您通常只会看到它们在应该通过的时候通过,在不应该通过的时候失败(因为它们测试的代码应该已经可以工作)。时分双工对此有很大帮助,这也是开发人员在练习 TDD 时比事后进行单元测试时进行的调试要少得多的原因之一。如果他们信任这些测试,他们就不会觉得需要调试它“以防万一”。这种信任只有通过了解测试的双方才能获得——该失败的时候失败,该通过的时候通过。
This book deals with readable, maintainable, and trustworthy tests, but if you add TDD on top, your confidence in your own tests will increase by seeing the failed, you fixed it, tests failing when they should and passing when they should. In test-after style, you’ll usually only see them pass when they should, and fail when they shouldn’t (since the code they test should already be working). TDD helps with that a lot, and it’s also one of the reasons developers do far less debugging when practicing TDD than when they’re simply unit testing after the fact. If they trust the tests, they don’t feel a need to debug it “just in case.” That’s the kind of trust you can only gain by seeing both sides of the test—failing when it should and passing when it should.
要在测试驱动开发中取得成功,您需要三种不同的技能:了解如何编写良好的测试、以测试为先编写测试以及良好地设计测试和生产代码。图 1.10 更清楚地显示了这些:
To be successful in test-driven development, you need three different skill sets: knowing how to write good tests, writing them test-first, and designing the tests and the production code well. Figure 1.10 shows these more clearly:
Just because you write your tests first doesn’t mean they’re maintainable, readable, or trustworthy. Good unit testing skills are what this book is all about.
仅仅因为您编写了可读、可维护的测试,并不意味着您将获得与先编写测试时相同的好处。大多数 TDD 书籍都教授测试优先的技能,但没有教授良好测试的技能。我特别推荐 Kent Beck 的《测试驱动开发:举例》(Addison-Wesley Professional,2002 年)。
Just because you write readable, maintainable tests doesn’t mean you’ll get the same benefits as when writing them test-first. Test-first skills are what most of the TDD books out there teach, without teaching the skills of good testing. I would especially recommend Kent Beck’s Test-Driven Development: By Example (Addison-Wesley Professional, 2002).
仅仅因为您首先编写了测试,并且它们具有可读性和可维护性,并不意味着您最终会得到一个设计良好的系统。设计技巧使您的代码变得美观且可维护。我推荐Steve Freeman 和 Nat Pryce 编写的《Growing Object-Oriented Software, Guided by Tests》(Addison-Wesley Professional,2009 年)和Robert C. Martin 编写的《Clean Code》(Pearson,2008 年)作为有关该主题的好书。
Just because you write your tests first, and they’re readable and maintainable, doesn’t mean you’ll end up with a well-designed system. Design skills are what make your code beautiful and maintainable. I recommend Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce (Addison-Wesley Professional, 2009) and Clean Code by Robert C. Martin (Pearson, 2008) as good books on the subject.
Figure 1.10 Three core skills of test-driven development
学习 TDD 的实用方法是分别学习这三个方面;也就是说,一次专注于一项技能,同时忽略其他技能。我推荐这种方法的原因是,我经常看到人们试图同时学习所有三种技能,在这个过程中经历了一段非常艰难的时期,最后因为墙太高而放弃。通过采取更渐进的方法来学习这个领域,你就可以摆脱那种担心自己在与当前关注的领域不同的领域中犯错的持续恐惧。
A pragmatic approach to learning TDD is to learn each of these three aspects separately; that is, to focus on one skill at a time, ignoring the others in the meantime. The reason I recommend this approach is that I often see people trying to learn all three skill sets at the same time, having a really hard time in the process, and finally giving up because the wall is too high to climb. By taking a more incremental approach to learning this field, you relieve yourself of the constant fear that you’re getting it wrong in a different area than you’re currently focusing on.
在下一章中,您将开始使用 Jest(最常用的 JavaScript 测试框架之一)编写第一个单元测试。
In the next chapter, you’ll start writing your first unit tests using Jest, one of the most commonly used test frameworks for JavaScript.
入口点是公共函数,是进入我们工作单元并触发底层逻辑的门户。退出点是您可以通过测试进行检查的地方。它们代表工作单元的效果。
Entry points are public functions that are the doorways into our units of work and trigger the underlying logic. Exit points are the places you can inspect with your test. They represent the effects of the units of work.
退出点可以是返回值、状态更改或对第三方依赖项的调用。每个出口点通常需要单独的测试,并且每种类型的出口点需要不同的测试技术。
An exit point can be a return value, a change of state, or a call to a third-party dependency. Each exit point usually requires a separate test, and each type of exit point requires a different testing technique.
工作单元是从调用入口点到通过一个或多个出口点产生明显的最终结果之间发生的操作的总和。一个工作单元可以跨越一个功能、一个模块或多个模块。
A unit of work is the sum of actions that take place between the invocation of an entry point up until a noticeable end result through one or more exit points. A unit of work can span a function, a module, or multiple modules.
集成测试只是单元测试,其中部分或全部依赖项是真实的并且位于当前执行过程之外。相反,单元测试就像集成测试,但所有依赖项都在内存中(真实的和虚假的),并且我们可以控制它们在测试中的行为。
Integration testing is just unit testing with some or all of the dependencies being real and residing outside of the current execution process. Conversely, unit testing is like integration testing, but with all of the dependencies in memory (both real and fake), and we have control over their behavior in the test.
任何测试最重要的属性是可读性、可维护性和可信性。可读性告诉我们阅读和理解测试的难易程度。可维护性衡量的是维护测试代码的痛苦程度。如果没有信任,就很难在代码库中引入重要的更改(例如重构),从而导致代码恶化。
The most important attributes of any test are readability, maintainability, and trust. Readability tells us how easy it is to read and understand the test. Maintainability is the measure of how painful it is to maintain the test code. Without trust, it’s harder to introduce important changes (such as refactoring) in a codebase, which leads to code deterioration.
Test-driven development (TDD) is a technique that advocates for writing tests before the production code. This approach is also referred to as a test-first approach (as opposed to code-first).
TDD 的主要好处是验证测试的正确性。在编写生产代码之前看到测试失败,可以确保如果它们所涵盖的功能停止正常工作,这些相同的测试也会失败。
The main benefit of TDD is verifying the correctness of your tests. Seeing your tests fail before writing production code ensures that these same tests would fail if the functionality they cover stops working properly.
当我第一次开始使用真正的单元测试框架编写单元测试时,几乎没有文档,而且我使用的框架没有适当的示例。(我主要使用 VB 5 进行编码和当时的6个。)学习与他们一起工作是一个挑战,我开始编写相当糟糕的测试。幸运的是,时代变了。在 JavaScript 以及几乎任何语言中,社区提供了广泛的选择、大量的文档和支持来尝试这些有用的包。
When I first started writing unit tests with a real unit testing framework, there was little documentation, and the frameworks I worked with didn’t have proper examples. (I was mostly coding in VB 5 and 6 at the time.) It was a challenge learning to work with them, and I started out writing rather poor tests. Fortunately, times have changed. In JavaScript, and in practically any language out there, there’s a wide range of choices and plenty of documentation and support from the community for trying out these bundles of helpfulness.
在上一章中,我们编写了一个非常简单的自制测试框架。在本章中,我们将了解 Jest,它将成为我们为本书选择的框架。
In the previous chapter, we wrote a very simple home-grown test framework. In this chapter, we’ll take a look at Jest, which will be our framework of choice for this book.
Jest 是 Facebook 创建的开源测试框架。它易于使用、易于记忆,并且具有许多出色的功能。Jest 最初是为了测试 JavaScript 中的前端 React 组件而创建的。如今,它广泛应用于行业的许多领域,用于后端和前端项目测试。它支持两种主要的测试语法(一种使用该词test,另一种基于 Jasmin 语法,Jasmin 语法是一个启发了 Jest 的许多功能的框架)。我们将尝试两者,看看我们更喜欢哪一个。
Jest is an open source test framework created by Facebook. It’s easy to use, easy to remember, and has lots of great features. Jest was originally created for testing frontend React components in JavaScript. These days it’s widely used in many parts of the industry for both backend and frontend project testing. It supports two major flavors of test syntax (one that uses the word test and another that’s based on the Jasmin syntax, a framework that has inspired many of Jest’s features). We’ll try both of them to see which one we like better.
除了 Jest 之外,JavaScript 中还有许多其他测试框架,而且几乎都是开源的。它们之间在风格和 API 方面存在一些差异,但就本书的目的而言,这应该不会太重要。
Aside from Jest, there are many other testing frameworks in JavaScript, pretty much all open source as well. There are some differences between them in style and APIs, but for the purposes of this book, that shouldn’t matter too much.
确保本地安装了 Node.js。您可以按照https://nodejs.org/en/download/上的说明将其启动并在您的计算机上运行。该站点将为您提供长期支持 (LTS) 版本或当前版本的选项。LTS 版本面向企业,而当前版本的更新更加频繁。两者都适用于本书的目的。
Make sure you have Node.js installed locally. You can follow the instructions at https://nodejs.org/en/download/ to get it up and running on your machine. The site will provide you with the option of either a long-term support (LTS) release or a current release. The LTS release is geared toward enterprises, whereas the current release has more frequent updates. Either will work for the purposes of this book.
确保您的计算机上安装了节点包管理器 (npm)。它包含在 Node.js 中,因此npm -v在命令行上运行该命令,如果您看到 6.10.2 或更高版本,则应该可以开始使用。如果没有,请确保已安装 Node.js。
Make sure that the node package manager (npm) is installed on your machine. It is included with Node.js, so run the command npm -v on the command line, and if you see a version of 6.10.2 or higher, you should be good to go. If not, make sure Node.js is installed.
开始使用 Jest,让我们创建一个名为“ch2”的新空文件夹,并使用您选择的包管理器对其进行初始化。我将使用 npm,因为我必须选择一个。Yarn 是另一种包管理器。就本书而言,您使用哪一个并不重要。
To get started with Jest, let’s create a new empty folder named “ch2” and initialize it with a package manager of your choice. I’ll use npm, since I have to choose one. Yarn is an alternative package manager. It shouldn’t matter, for the purposes of this book, which one you use.
Jest 需要 jest.config.js 或 package.json 文件。我们选择后者,并将npm init为我们生成一个:
Jest expects either a jest.config.js or a package.json file. We’re going with the latter, and npm init will generate one for us:
mkdir ch2 光盘频道2 npm 初始化——是的 //或者 纱线初始化-是 git初始化
mkdir ch2 cd ch2 npm init --yes //or yarn init -yes git init
我还在这个文件夹中初始化 Git。无论如何,建议您这样做,以跟踪更改,但对于 Jest 来说,此文件在幕后用于跟踪文件更改并运行特定测试。这让 Jest 的生活变得更轻松。
I’m also initializing Git in this folder. This would be recommended anyway, to track changes, but for Jest this file is used under the covers to track changes to files and run specific tests. It makes Jest’s life easier.
默认情况下,Jest 将在由此命令创建的 package.json 文件或特殊的 jest.config.js 文件中查找其配置。目前,除了默认的 package.json 文件之外,我们不需要任何东西。如果您想了解有关 Jest 配置选项的更多信息,请参阅https://jestjs.io/docs/en/configuration。
By default, Jest will look for its configuration either in the package.json file that is created by this command or in a special jest.config.js file. For now, we won’t need anything but the default package.json file. If you’d like to learn more about the Jest configuration options, refer to https://jestjs.io/docs/en/configuration.
接下来,我们将安装 Jest。要将 Jest 安装为开发依赖项(这意味着它不会分发到生产环境),我们可以使用以下命令:
Next, we’ll install Jest. To install Jest as a dev dependency (which means it does not get distributed to production) we can use this command:
npm install --save-dev 玩笑 //或者 纱线添加笑话-dev
npm install --save-dev jest //or yarn add jest -dev
这将在我们的[根文件夹]/node_modules/bin 下创建一个新的 jest.js 文件。然后我们可以使用该npx jest命令执行 Jest。
This will create a new jest.js file under our [root folder]/node_modules/bin. We can then execute Jest using the npx jest command.
我们还可以通过执行以下命令在本地计算机上全局save-dev安装 Jest(我建议在安装的基础上执行此操作):
We can also install Jest globally on the local machine (I recommend doing this on top of the save-dev installation) by executing this command:
npm install -g 开玩笑
npm install -g jest
这将使我们可以自由地jest在任何具有测试的文件夹中直接从命令行执行命令,而无需通过 npm 来执行它。
This will give us the freedom to execute the jest command directly from the command line in any folder that has tests, without going through npm to execute it.
在实际项目中,通常使用npm命令来运行测试而不是使用全局jest. 我将在接下来的几页中展示这是如何完成的。
In real projects, it is common to use npm commands to run tests instead of using the global jest. I’ll show how this is done in the next few pages.
Jest has a couple of default ways to find test files:
If there’s a __tests__ folder, it loads all the files in it as test files, regardless of their naming conventions.
It tries to find any file that ends with *.spec.js or *.test.js, in any folder under the root folder of your project, recursively.
我们将使用第一个变体,但我们也会使用 *test.js 或 *.spec.js 来命名我们的文件,以使事情更加一致,以防我们稍后想要移动它们(并停止使用 __tests_文件夹)。
We’ll use the first variation, but we’ll also name our files with either *test.js or *.spec.js to make things a bit more consistent in case we want to move them around later (and stop using the __tests_ folder altogether).
您还可以根据自己的喜好配置 Jest,使用 jest.config.js 文件或通过 package.json 指定如何查找哪些文件。您可以在https://jestjs.io/docs/en/configuration查找 Jest 文档以查找所有血淋淋的细节。
You can also configure Jest to your heart’s content, specifying how to find which files where, with a jest.config.js file or through package.json. You can look up the Jest docs at https://jestjs.io/docs/en/configuration to find all the gory details.
下一步是在 ch2 文件夹下创建一个名为 __tests__ 的特殊文件夹。在此文件夹下,创建一个以 test.js 或 spec.js 结尾的文件,例如 my-component.test.js。您选择哪个后缀取决于您自己——这取决于您自己的风格。我将在本书中互换使用它们,因为我认为“测试”是“规范”的最简单版本,因此在展示非常简单的东西时我会使用它。
The next step is to create a special folder under our ch2 folder called __tests__. Under this folder, create a file that ends with either test.js or spec.js—my-component.test.js, for example. Which suffix you choose is up to you—it’s about your own style. I’ll use them interchangeably in this book because I think of “test” as the simplest version of “spec,” so I use it when showing very simple things.
我们不需要require()从文件顶部开始使用 Jest。它会自动导入全局函数供我们使用。您应该感兴趣的主要函数包括test、describe、it和expect。清单 2.1 显示了一个简单的测试可能是什么样子。
We don’t need require() at the top of the file to start using Jest. It automatically imports global functions for us to use. The main functions you should be interested in include test, describe, it, and expect. Listing 2.1 shows what a simple test might look like.
test ('hello jest', () => {
Expect ('hello').toEqual('再见');
});test('hello jest', () => {
expect('hello').toEqual('goodbye');
});
我们还没有使用describe过it,但我们很快就会使用它们。
We haven’t used describe and it yet, but we’ll get to them soon.
要运行此测试,我们需要能够执行 Jest。为了从命令行识别 Jest,我们需要执行以下操作之一:
To run this test, we need to be able to execute Jest. For Jest to be recognized from the command line, we need to do either of the following:
Install Jest globally on the machine by running npm install jest -g.
Use npx to execute Jest from the node_modules directory by typing jest in the root of the ch2 folder.
如果所有星星都正确排列,您应该会看到 Jest 测试运行的结果和失败。你的第一次失败。耶!图 2.1 显示了运行该命令时终端上的输出。看到测试工具如此可爱、丰富多彩(如果您正在阅读电子书)、有用的输出,真是太酷了。如果您的终端处于深色模式,它看起来会更酷。
If all the stars lined up correctly, you should see the results of the Jest test run and a failure. Your first failure. Yay! Figure 2.1 shows the output on my terminal when I run the command. It’s pretty cool to see such lovely, colorful (if you’re reading the e-book), useful output from a test tool. It looks even cooler if your terminal is in dark mode.
Figure 2.1 Terminal output from Jest
让我们仔细看看细节。图 2.2 显示了相同的输出,但后面带有数字。让我们看看这里提供了多少条信息:
Let’s take a closer look at the details. Figure 2.2 shows the same output, but with numbers to follow along. Let’s see how many pieces of information are presented here:
❶ A quick list of all the failing tests (with names) with nice red Xs next to them
❷ A detailed report on the expectation that failed (aka our assertion)
❸ The exact difference between the actual value and expected value
❹ The type of comparison that was executed
❻ The exact line (visually) where the test failed
❼ A report of how many tests ran, failed, and passed
❾ The number of snapshots (not relevant to our discussion)
Figure 2.2 Annotated terminal output from Jest
想象一下尝试自己编写所有这些报告功能。有可能,但是谁有时间和意愿呢?另外,您还必须处理报告机制中的任何错误。
Imagine trying to write all this reporting functionality yourself. It’s possible, but who’s got the time and the inclination? Plus, you’d have to take care of any bugs in the reporting mechanism.
如果我们在测试中更改goodbye为,我们可以看到测试通过后会发生什么(图2.3)。hello漂亮又绿色,一切都应该如此(同样,在数字版本中,否则它是漂亮的灰色)。
If we change goodbye to hello in the test, we can see what happens when the test passes (figure 2.3). Nice and green, as all things should be (again, in the digital version—otherwise it’s nice and grey).
Figure 2.3 Jest terminal output for a passing test
您可能会注意到,运行此单个 Hello World 测试需要 1.5 秒。如果我们使用该命令jest --watch,我们可以让 Jest 监视文件夹中的文件系统活动,并自动对已更改的文件运行测试,而无需每次都重新初始化。这可以节省大量时间,并且对持续测试的整个概念确实有帮助。在工作站的另一个窗口中设置一个终端jest --watch,您可以继续编码并获得有关您可能创建的问题的快速反馈。这是进入事物流程的好方法。
You might note that it takes 1.5 seconds to run this single Hello World test. If we used the command jest --watch instead, we could have Jest monitor filesystem activity in our folder and automatically run tests for files that have changed without re-initializing itself every time. This can save a considerable amount of time, and it really helps with the whole notion of continuous testing. Set a terminal in the other window of your workstation with jest --watch on it, and you can keep coding and getting fast feedback on issues you might be creating. That’s a good way to get into the flow of things.
Jest 还支持异步风格的测试和回调。当我们在本书后面讨论这些主题时,我将触及这些内容,但如果您现在想了解有关这种风格的更多信息,请转到有关该主题的 Jest 文档:https: //jestjs.io/docs /en/异步.
Jest also supports async-style testing and callbacks. I’ll touch on these when we get to those topics later in the book, but if you’d like to learn more about this style now, head over to the Jest documentation on the subject: https://jestjs.io/docs/en/asynchronous.
Jest has acted in several capacities for us:
Jest 还提供隔离设施来创建模拟、桩和间谍,尽管我们还没有看到。我们将在后面的章节中讨论这些想法。
Jest also provides isolation facilities to create mocks, stubs, and spies, though we haven’t seen that yet. We’ll touch on these ideas in later chapters.
除了隔离设施之外,在其他语言中,测试框架扮演我刚才提到的所有角色(库、断言、测试运行程序和测试报告程序)是很常见的,但 JavaScript 世界似乎更加分散。许多其他测试框架仅提供其中一些功能。也许是因为“只做一件事,并把它做好”的口号被牢记在心,也许还有其他原因。无论如何,Jest 作为少数一体化框架之一脱颖而出。JavaScript 开源文化的力量证明了,对于每一类,都有多种工具可供您混合搭配来创建您自己的超级工具集。
Other than isolation facilities, it’s very common in other languages for a test framework to fill all the roles I just mentioned—library, assertions, test runner, and test reporter—but the JavaScript world seems a bit more fragmented. Many other test frameworks provide only some of these facilities. Perhaps this is because the mantra of “do one thing, and do it well” has been taken to heart, or perhaps it’s for other reasons. In any case, Jest stands out as one of a handful of all-in-one frameworks. It is a testament to the strength of the open source culture in JavaScript that for each one of these categories, there are multiple tools that you can mix and match to create your own super toolset.
我为这本书选择 Jest 的原因之一是这样我们就不必过多地担心工具或处理缺失的功能——我们可以只关注模式。这样我们就不必在一本主要关注模式和反模式的书中使用多个框架。
One of the reasons I chose Jest for this book is so we don’t have to bother too much with the tooling or deal with missing features—we can just focus on the patterns. That way we won’t have to use multiple frameworks in a book that is mostly concerned with patterns and antipatterns.
让我们缩小一下看看我们在哪里。像 Jest 这样的框架比创建我们自己的框架(就像我们在上一章中开始做的那样)或手动测试东西能给我们提供什么?
Let’s zoom out for a second and see where we are. What do frameworks like Jest offer us over creating our own framework, like we started to do in the previous chapter, or over manually testing things?
结构——当您使用测试框架时,您不必每次想要测试某个功能时都重新发明轮子,而是总是以相同的方式开始——编写一个具有明确定义的结构的测试,让每个人都可以轻松识别、阅读和理解。
Structure—Instead of reinventing the wheel every time you want to test a feature, when you use a test framework you always start out the same way—by writing a test with a well-defined structure that everyone can easily recognize, read, and understand.
可重复性——使用测试框架时,很容易重复编写新测试的行为。使用测试运行器重复执行测试也很容易,并且每天可以多次快速地执行此操作。理解失败及其原因也很容易。有人已经为我们完成了所有艰苦的工作,而不是我们必须将所有这些东西编码到我们手写的框架中。
Repeatability—When using a test framework, it’s easy to repeat the act of writing a new test. It’s also easy to repeat the execution of the test, using a test runner, and it’s easy to do this quickly and many times a day. It’s also easy to understand failures and their causes. Someone has already done all the hard work for us, instead of us having to code all that stuff into our hand-rolled framework.
信心和节省时间——当我们推出自己的测试框架时,该框架更有可能存在错误,因为它比现有的成熟且广泛使用的框架更少经过实战测试。另一方面,手动测试通常非常耗时。当我们时间紧迫时,我们可能会专注于测试那些感觉最关键的事情,并跳过那些可能感觉不那么重要的事情。我们可以跳过小但重要的错误。通过使编写新测试变得容易,我们更有可能为那些感觉不那么重要的东西编写测试,因为我们不会花太多时间为大的东西编写测试。
Confidence and time savings—When we roll our own test framework, the framework is more likely to have bugs in it, since it is less battle-tested than an existing mature and widely used framework. On the other hand, manually testing things is usually very time consuming. When we’re short on time, we’ll likely focus on testing the things that feel the most critical and skip over things that might feel less important. We could be skipping small but significant bugs. By making it easy to write new tests, it’s more likely that we’ll also write tests for the stuff that feels less significant because we won’t be spending too much time writing tests for the big stuff.
Shared understanding—The framework’s reporting can be helpful for managing tasks at the team level (when a test is passing, it means the task is done). Some people find this useful.
简而言之,用于编写、运行和审查单元测试及其结果的框架可以对愿意投入时间学习如何正确使用它们的开发人员的日常生活产生巨大的影响。图 2.4 显示了单元测试框架及其辅助工具在软件开发中具有影响力的领域,表 2.1 列出了我们通常使用测试框架执行的操作类型。
In short, frameworks for writing, running, and reviewing unit tests and their results can make a huge difference in the daily lives of developers who are willing to invest the time in learning how to use them properly. Figure 2.4 shows the areas in software development in which a unit testing framework and its helper tools have influence, and table 2.1 lists the types of actions we usually execute with a test framework.
图 2.4 单元测试使用单元测试框架中的库编写为代码。测试从 IDE 内的测试运行程序或通过命令行运行,并且开发人员或自动构建过程通过测试报告器(作为输出文本或在 IDE 中)审查结果。
Figure 2.4 Unit tests are written as code, using libraries from the unit testing framework. The tests are run from a test runner inside the IDE or through the command line, and the results are reviewed through a test reporter (either as output text or in the IDE) by the developer or an automated build process.
表 2.1 测试框架如何帮助开发人员编写和执行测试并审查结果
Table 2.1 How testing frameworks help developers write and execute tests and review results
|
A framework supplies the developer with helper functions, assertion functions, and structure-related functions. |
|
|
A framework provides a test runner, usually at the command line, that |
|
在撰写本文时,大约有 900 个单元测试框架,其中有两个以上适用于大多数公共使用的编程语言(还有一些已失效的框架)。您可以在维基百科上找到一个很好的列表:https ://en.wikipedia.org/wiki/List_of_unit_testing_frameworks 。
At the time of writing, there are around 900 unit testing frameworks out there, with more than a couple for most programming languages in public use (and a few dead ones). You can find a good list on Wikipedia: https://en.wikipedia.org/wiki/List_of_unit_testing_frameworks.
注意使用单元测试框架并不能确保您编写的测试是可读的、可维护的或值得信赖的,或者它们涵盖了您想要测试的所有逻辑。我们将在第 7 章到第 9 章以及本书的其他各个地方讨论如何确保您的单元测试具有这些属性。
Note Using a unit testing framework doesn’t ensure that the tests you write are readable, maintainable, or trustworthy, or that they cover all the logic you’d like to test. We’ll look at how to ensure your unit tests have these properties in chapters 7 through 9 and in various other places throughout this book.
当我开始编写测试时(在 Visual Basic 时代),衡量大多数单元测试框架的标准统称为 xUnit。xUnit 框架思想的鼻祖是 SUnit,Smalltalk 的单元测试框架。
When I started writing tests (in the Visual Basic days), the standard by which most unit test frameworks were measured was collectively called xUnit. The grandfather of the xUnit frameworks idea was SUnit, the unit testing framework for Smalltalk.
这些单元测试框架的名称通常以其构建语言的首字母开头;您可能有用于 C++ 的 CppUnit、用于 Java 的 JUnit、用于 .NET 的 NUnit 和 xUnit 以及用于 Haskell 编程语言的 HUnit。并非所有这些都遵循这些命名准则,但大多数都遵循。
These unit testing frameworks’ names usually start with the first letters of the language for which they were built; you might have CppUnit for C++, JUnit for Java, NUnit and xUnit for .NET, and HUnit for the Haskell programming language. Not all of them follow these naming guidelines, but most do.
不仅仅是名称相当一致。如果您使用的是 xUnit 框架,您还可以期望构建测试的特定结构。当这些框架运行时,它们将以相同的结构输出结果,通常是具有特定模式的 XML 文件。
It’s not just the names that were reasonably consistent. If you were using an xUnit framework, you could also expect a specific structure in which the tests were built. When these frameworks would run, they would output their results in the same structure, which was usually an XML file with a specific schema.
这种类型的 xUnit XML 报告至今仍然很流行,并且广泛应用于大多数构建工具,例如 Jenkins,它通过本机插件支持这种格式,并使用它来报告测试运行的结果。大多数静态语言的单元测试框架仍然使用 xUnit 模型进行结构,这意味着一旦您学会使用其中一种,您应该能够轻松使用它们中的任何一种(假设您了解特定的编程语言)。
This type of xUnit XML report is still prevalent today, and it’s widely used in most build tools, such as Jenkins, which support this format with native plugins and use it to report the results of test runs. Most unit test frameworks in static languages still use the xUnit model for structure, which means that once you’ve learned to use one of them, you should be able to easily use any of them (assuming you know the particular programming language).
测试结果报告结构的另一个有趣的标准称为TAP,即测试任何协议。TAP 最初是 Perl 测试工具的一部分,但现在它可以用 C、C++、Python、PHP、Perl、Java、JavaScript 和其他语言实现。TAP 不仅仅是一个报告规范。在 JavaScript 世界中,TAP 框架是最著名的原生支持 TAP 协议的测试框架。
The other interesting standard for the reporting structure of test results and more is called TAP, the Test Anything Protocol. TAP started life as part of the test harness for Perl, but now it has implementations in C, C++, Python, PHP, Perl, Java, JavaScript, and other languages. TAP is much more than just a reporting specification. In the JavaScript world, the TAP framework is the best-known test framework that natively supports the TAP protocol.
严格来说,Jest 并不是 xUnit 或 TAP 框架。默认情况下,其输出不兼容 xUnit 或 TAP。然而,由于 xUnit 风格的报告仍然统治着构建领域,因此我们通常希望在构建服务器上的报告中采用该协议。要获得大多数构建工具都能轻松识别的 Jest 测试结果,您可以安装 npm 模块,例如jest-xunit(如果您想要特定于 TAP 的输出,请使用jest-tap-reporter),然后在项目中使用特殊的 jest.config.js 文件来配置 Jest改变其报告格式。
Jest is not strictly an xUnit or TAP framework. Its output is not xUnit- or TAP-compliant by default. However, because xUnit-style reporting still rules the build sphere, we’ll usually want to adapt to that protocol for our reporting on a build server. To get Jest test results that are easily recognized by most build tools, you can install npm modules such as jest-xunit (if you want TAP-specific output, use jest-tap-reporter) and then use a special jest.config.js file in your project to configure Jest to alter its reporting format.
现在让我们继续写一些感觉更像是 Jest 的真实测试的东西,好吗?
Now let’s move on and write something that feels a bit more like a real test with Jest, shall we?
我们主要用于测试本书中的示例的项目一开始很简单,仅包含一个函数。随着本书的进展,我们将使用新功能、模块和类来扩展该项目,以演示单元测试的不同方面。我们将其称为密码验证器项目。
The project that we’ll mostly use for testing examples in this book will start out simple, containing only one function. As the book moves along, we’ll extend that project with new features, modules, and classes to demonstrate different aspects of unit testing. We’ll call it the Password Verifier project.
第一个场景非常简单。我们将构建一个密码验证库,它首先只是一个函数。函数verifyPassword(rules)允许我们放入名为 的自定义验证函数rules,并根据输入的规则输出错误列表。每个规则函数将输出两个字段:
The first scenario is pretty simple. We’ll be building a password verification library, and it will just be a function at first. The function, verifyPassword(rules), allows us to put in custom verification functions dubbed rules, and it outputs the list of errors, according to the rules that have been input. Each rule function will output two fields:
{
通过:(布尔值),
原因:(字符串)
}{
passed: (boolean),
reason: (string)
}
在本书中,我将教您编写测试,以便verifyPassword在我们向其添加更多功能时以多种方式检查其功能。
In this book, I’ll teach you to write tests that check verifyPassword’s functionality in multiple ways as we add more features to it.
The following listing shows version 0 of this function, with a very naive implementation.
Listing 2.2 Password Verifier version 0
const verifyPassword = (输入, 规则) => {
常量错误=[];
规则.forEach(规则=> {
const 结果 = 规则(输入);
if (!result.passed) {
error.push(`错误${result.reason}`);
}
});
返回错误;
};const verifyPassword = (input, rules) => {
const errors = [];
rules.forEach(rule => {
const result = rule(input);
if (!result.passed) {
errors.push(`error ${result.reason}`);
}
});
return errors;
};
当然,这不是最实用的代码,我们可能会稍后重构它,但我想让这里的事情保持非常简单,这样我们就可以专注于测试。
Granted, this is not the most functional-style code, and we might refactor it a bit later, but I wanted to keep things very simple here so we can focus on the tests.
该功能实际上并没有做太多事情。它迭代给定的所有规则,并使用提供的输入运行每一个规则。如果规则的结果未通过,则会将错误添加到作为最终结果返回的最终错误数组中。
The function doesn’t really do much. It iterates over all the rules given and runs each one with the supplied input. If the rule’s result is not passed, then an error is added to the final errors array that is returned as the final result.
假设您已经安装了 Jest,您可以继续在 __tests__ 文件夹下创建一个名为password-verifier0.spec.js 的新文件。
Assuming you have Jest installed, you can go ahead and create a new file named password-verifier0.spec.js under the __tests__ folder.
使用 __tests__ 文件夹只是组织测试的一种约定,它是 Jest 默认配置的一部分。许多人喜欢将测试文件与正在测试的代码放在一起。每种方法都有优点和缺点,我们将在本书的后面部分进行讨论。现在,我们将使用默认值。
Using the __tests__ folder is only one convention for organizing your tests, and it’s part of Jest’s default configuration. There are many who prefer to place the test files alongside the code being tested. There are pros and cons to each approach, and we’ll get into that in later parts of the book. For now, we’ll go with the defaults.
Here’s a first version of a test against our new function.
Listing 2.3 The first test against verifyPassword()
test('命名错误的测试', () => {
const fakeRule = input => ❶
({ pass: false, Reason: '假原因' }); ❶
const error = verifyPassword('任何值', [fakeRule]); ❷
Expect(errors[0]).toMatch('假原因'); ❸
});test('badly named test', () => {
const fakeRule = input => ❶
({ passed: false, reason: 'fake reason' }); ❶
const errors = verifyPassword('any value', [fakeRule]); ❷
expect(errors[0]).toMatch('fake reason'); ❸
});
❶ Setting up inputs for the test
❷ Invoking the entry point with the inputs
清单 2.3 中的测试结构通俗地称为Arrange-Act-Assert (AAA) 模式。相当不错!我发现通过说“‘安排’部分太复杂”或“‘行动’部分在哪里?”之类的话来推理测试的各个部分非常容易。
The structure of the test in listing 2.3 is colloquially called the Arrange-Act-Assert (AAA) pattern. It’s quite nice! I find it very easy to reason about the parts of a test by saying things like “that ‘arrange’ part is too complicated” or “where is the ‘act’ part?”
在安排部分,我们创建了一个总是返回 false 的假规则,以便我们可以通过在测试结束时断言其原因来证明它确实被使用。verifyPassword然后我们将其与简单的输入一起发送。我们在断言部分检查我们得到的第一个错误是否与我们在排列部分给出的虚假原因匹配。.toMatch(/string/)使用正则表达式来查找字符串的一部分。这与使用相同.toContain('fake reason')。
In the arrange part, we’re creating a fake rule that always returns false, so that we can prove it’s actually used by asserting on its reason at the end of the test. We then send it to verifyPassword along with a simple input. We check in the assert section that the first error we get matches the fake reason we gave in the arrange part. .toMatch(/string/) uses a regular expression to find a part of the string. It’s the same as using .toContain('fake reason').
在编写测试或修复某些内容后手动运行 Jest 很乏味,因此让我们配置 npm 以自动运行 Jest。进入ch2根文件夹下的package.json,在该scriptsitem下添加以下内容:
It’s tedious to run Jest manually after we write a test or fix something, so let’s configure npm to run Jest automatically. Go to package.json in the root folder of ch2 and add the following items under the scripts item:
"scripts": {
"test": "jest",
"testw": "jest --watch" //如果不使用 git,则改为 --watchAll
},"scripts": {
"test": "jest",
"testw": "jest --watch" //if not using git, change to --watchAll
},
如果您没有在此文件夹中初始化 Git,则可以使用该命令--watchAll而不是--watch.
If you don’t have Git initialized in this folder, you can use the command --watchAll instead of --watch.
如果一切顺利,您现在可以npm test从 ch2 文件夹中输入命令行,Jest 将运行一次测试。如果您输入npm run testw,Jest 将运行并无限循环地等待更改,直到您使用 Ctrl-C 终止该进程。(您需要使用该单词,run因为testw它不是 npm 自动识别的特殊关键字之一。)
If everything went well, you can now type npm test in the command line from the ch2 folder, and Jest will run the tests once. If you type npm run testw, Jest will run and wait for changes in an endless loop, until you kill the process with Ctrl-C. (You need to use the word run because testw is not one of the special keywords that npm recognizes automatically.)
If you run the test, you can see that it passes, since the function works as expected.
让我们在生产代码中添加一个错误,看看测试是否会在应该失败的时候失败。
Let’s put a bug in the production code and see if the test fails when it should.
const verifyPassword = (输入, 规则) => {
常量错误=[];
规则.forEach(规则=> {
const 结果 = 规则(输入);
if (!result.passed) {
// error.push(`错误 ${result.reason}`); ❶
}
});
返回错误;
};const verifyPassword = (input, rules) => {
const errors = [];
rules.forEach(rule => {
const result = rule(input);
if (!result.passed) {
// errors.push(`error ${result.reason}`); ❶
}
});
return errors;
};
❶ We've accidentally commented out this line.
您现在应该看到您的测试失败并显示一条不错的消息。让我们取消注释该行并再次查看测试是否通过。如果您不进行测试驱动开发并且在代码之后编写测试,那么这是获得对测试的信心的好方法。
You should now see your test failing with a nice message. Let’s uncomment the line and see the test pass again. This is a great way to gain some confidence in your tests, if you’re not doing test-driven development and are writing the tests after the code.
我们的测试名声很不好。它没有解释我们在这里想要完成的任何事情。我喜欢在测试名称中放入三条信息,这样测试的读者只需查看测试名称就可以回答他们的大部分心理问题。这三个部分包括
Our test has a really bad name. It doesn’t explain anything about what we’re trying to accomplish here. I like to put three pieces of information in test names, so that the reader of the test will be able to answer most of their mental questions just by looking at the test name. These three parts include
The unit of work under test (the verifyPassword function, in this case)
The expected behavior or exit point (returns an error with a reason)
在审阅过程中,该书的审阅者泰勒·莱姆克(Tyler Lemke)为此想出了一个很好的缩写词:USE:被测单元、场景、期望。我喜欢它,而且很容易记住。谢谢泰勒!
During the review process, Tyler Lemke, a reviewer of the book, came up with a nice acronym for this, USE: unit under test, scenario, expectation. I like it, and it’s easy to remember. Thanks Tyler!
以下列表显示了我们使用 USE 名称对测试进行的下一个修订版。
The following listing shows our next revision of the test with a USE name.
Listing 2.5 Naming a test with USE
test(' verifyPassword,给定失败的规则,返回错误', () => {
const fakeRule = input => ({ pass: false, Reason: '假原因' });
const error = verifyPassword('任何值', [fakeRule]);
Expect(errors[0]) .toContain( '假原因' ) ;
});test('verifyPassword, given a failing rule, returns errors', () => {
const fakeRule = input => ({ passed: false, reason: 'fake reason' });
const errors = verifyPassword('any value', [fakeRule]);
expect(errors[0]).toContain('fake reason');
});
这个好一点了。当测试失败时,特别是在构建过程中,您看不到注释或完整的测试代码。您通常只会看到测试的名称。名称应该非常清晰,您甚至不需要查看测试代码就可以了解生产代码问题可能出在哪里。
This is a bit better. When a test fails, especially during a build process, you don’t see comments or the full test code. You usually only see the name of the test. The name should be so clear that you might not even have to look at the test code to understand where the production code problem might be.
We also made another small change in the following line:
Expect(errors[0]) .toContain ('假原因');expect(errors[0]).toContain('fake reason');
我们不是像测试中常见的那样检查一个字符串是否与另一个字符串相等,而是检查输出中是否包含一个字符串。这使得我们的测试对于未来输出的变化不那么脆弱。我们可以使用.toContainor.toMatch(/fake reason/)来实现这一点,它使用正则表达式来匹配字符串的一部分。
Instead of checking that one string is equal to another, as is very common in tests, we are checking that a string is contained in the output. This makes our test less brittle for future changes to the output. We can use .toContain or .toMatch(/fake reason/), which uses a regular expression to match a part of the string, to achieve this.
字符串是用户界面的一种形式。它们对人类来说是可见的,并且可能会发生变化——尤其是弦的边缘。我们可能会在字符串中添加空格、制表符、星号或其他修饰符。我们关心字符串中包含的核心信息是否存在。我们不想每次有人在字符串末尾添加新行时都更改我们的测试。这是我们希望在测试中鼓励的思维的一部分:随着时间的推移,测试的可维护性和对测试脆弱性的抵抗力是重中之重。
Strings are a form of user interface. They are visible to humans, and they might change—especially the edges of strings. We might add whitespace, tabs, asterisks, or other embellishments to a string. We care that the core of the information contained in the string exists. We don’t want to change our test every time someone adds a new line to the end of a string. This is part of the thinking we want to encourage in our tests: test maintainability over time, and resistance to test brittleness, are of high priority.
理想情况下,我们希望测试仅在生产代码中确实出现错误时才失败。我们希望将误报数量减少到最低限度。使用toContain()ortoMatch()是实现该目标的好方法。
We’d ideally like the test to fail only when something is actually wrong in the production code. We’d like to reduce the number of false positives to a minimum. Using toContain() or toMatch() is a great way to move toward that goal.
我将在整本书中讨论更多提高测试可维护性的方法,特别是在本书的第二部分。
I’ll talk about more ways to improve test maintainability throughout the book, and especially in part 2 of the book.
我们可以使用 Jest 的describe()函数围绕我们的测试创建更多结构,并开始将三个 USE 信息相互分离。这一步和之后的步骤完全由您决定——您可以决定如何设计测试及其可读性结构。我向您展示这些步骤是因为许多人要么没有describe()以有效的方式使用,要么完全忽略它。它可能非常有用。
We can use Jest’s describe() function to create a bit more structure around our test and to start separating the three USE pieces of information from each other. This step and the ones after it are completely up you—you can decide how you want to style your test and its readability structure. I’m showing you these steps because many people either don’t use describe() in an effective way, or they ignore it altogether. It can be quite useful.
这些describe()函数用上下文包装我们的测试:既为读者提供逻辑上下文,又为测试本身提供功能上下文。下一个清单显示了我们如何开始使用它们。
The describe() functions wrap our tests with context: both logical context for the reader, and functional context for the test itself. The next listing shows how we can start using them.
Listing 2.6 Adding a describe() block
描述('验证密码',()=> {
test('给出失败的规则,返回错误', () => {
常量 fakeRule = 输入 =>
({ pass: false, Reason: '假原因' });
const error = verifyPassword('任何值', [fakeRule]);
Expect(errors[0]).toContain('假原因');
});
});describe('verifyPassword', () => {
test('given a failing rule, returns errors', () => {
const fakeRule = input =>
({ passed: false, reason: 'fake reason' });
const errors = verifyPassword('any value', [fakeRule]);
expect(errors[0]).toContain('fake reason');
});
});
我添加了一个describe()块来描述被测试的工作单元。对我来说,这看起来更清楚。感觉我现在可以在该块下添加更多嵌套测试。该describe()块还可以帮助命令行报告器创建更好的报告。
I’ve added a describe() block that describes the unit of work under test. To me this looks clearer. It also feels like I can now add more nested tests under that block. This describe() block also helps the command-line reporter create nicer reports.
I’ve nested the test under the new block and removed the name of the unit of work from the test.
I’ve added an empty line between the arrange, act, and assert parts to make the test more readable, especially to someone new to the team.
好处describe()是它可以嵌套在自身之下。因此,我们可以使用它来创建另一个级别来解释场景,并在其下嵌套我们的测试。
The nice thing about describe() is that it can be nested under itself. So we can use it to create another level that explains the scenario, and under that we’ll nest our test.
Listing 2.7 Nested describes for extra context
描述('verifyPassword', () => {
描述('规则失败', () => {
测试('返回错误', () => {
const fakeRule = 输入 => ({ 传递: false,
原因:'假原因'});
const error = verifyPassword('任何值', [fakeRule]);
Expect(errors[0]).toContain('假原因');
});
});
});describe('verifyPassword', () => {
describe('with a failing rule', () => {
test('returns errors', () => {
const fakeRule = input => ({ passed: false,
reason: 'fake reason' });
const errors = verifyPassword('any value', [fakeRule]);
expect(errors[0]).toContain('fake reason');
});
});
});
有些人会讨厌它,但我认为它有一定的优雅。这种嵌套允许我们将三部分关键信息分离到各自的级别。describe()事实上,如果我们愿意的话,我们还可以在相关的测试之外提取错误规则。
Some people will hate it, but I think there’s a certain elegance to it. This nesting allows us to separate the three pieces of critical information to their own level. In fact, we can also extract the false rule outside of the test right under the relevant describe(), if we wish to.
Listing 2.8 Nested describes with an extracted input
描述('验证密码',()=> {
描述('有一个失败的规则', () => {
const fakeRule = input => ({ pass: false,
Reason: '假原因' });
test('返回错误', () => {
const error = verifyPassword('任何值', [fakeRule]);
Expect(errors[0]).toContain('假原因');
});
});
});describe('verifyPassword', () => {
describe('with a failing rule', () => {
const fakeRule = input => ({ passed: false,
reason: 'fake reason' });
test('returns errors', () => {
const errors = verifyPassword('any value', [fakeRule]);
expect(errors[0]).toContain('fake reason');
});
});
});
对于下一个示例,我将把这条规则移回到测试中(我喜欢事情紧密结合在一起——稍后会详细介绍)。
For the next example, I’ll move this rule back into the test (I like it when things are close together—more on that later).
这种嵌套结构还很好地表明,在特定场景下,您可能会有多个预期行为。您可以在一个场景下检查多个出口点,每个出口点作为一个单独的测试,从读者的角度来看它仍然有意义。
This nesting structure also implies very nicely that under a specific scenario you could have more than one expected behavior. You could check multiple exit points under a scenario, with each one as a separate test, and it will still make sense from the reader’s point of view.
到目前为止,我一直在构建的拼图中缺少一块。Jest 还公开了一个it()函数。出于所有意图和目的,该函数是该函数的别名test(),但它在语法方面更适合迄今为止概述的描述驱动方法。
There’s one missing piece to the puzzle I’ve been building so far. Jest also exposes an it() function. This function is, for all intents and purposes, an alias to the test() function, but it fits in more nicely in terms of syntax with the describe-driven approach outlined so far.
The following listing shows what the test looks like when I replace test() with it().
Listing 2.9 Replacing test() with it()
描述('验证密码',()=> {
描述('有一个失败的规则', () => {
it( '返回错误', () => {
const fakeRule = 输入 => ({ 传递: false,
原因:'假原因'});
const error = verifyPassword('任何值', [fakeRule]);
Expect(errors[0]).toContain('假原因');
});
});
});describe('verifyPassword', () => {
describe('with a failing rule', () => {
it('returns errors', () => {
const fakeRule = input => ({ passed: false,
reason: 'fake reason' });
const errors = verifyPassword('any value', [fakeRule]);
expect(errors[0]).toContain('fake reason');
});
});
});
在这个测试中,很容易理解it所指的是什么。这是先前块的自然扩展describe()。同样,是否要使用这种样式取决于您。我正在展示我喜欢的思考方式的一种变体。
In this test, it’s very easy to understand what it refers to. This is a natural extension of the previous describe() blocks. Again, it’s up to you whether you want to use this style. I’m showing one variation of how I like to think about it.
正如您所看到的,Jest 支持两种主要的测试编写方式:简洁的test语法和更describe驱动(即分层)的语法。
As you’ve seen, Jest supports two main ways to write tests: a terse test syntax, and a more describe-driven (i.e., hierarchical) syntax.
驱动的 Jest语法describe很大程度上归功于 Jasmine,它是最古老的 JavaScript 测试框架之一。这种风格本身可以追溯到Ruby-land和著名的RSpec Ruby测试框架。这种嵌套风格通常称为BDD风格,指的是行为驱动开发。
The describe-driven Jest syntax can be largely attributed to Jasmine, one of the oldest JavaScript test frameworks. The style itself can be traced back to Ruby-land and the well-known RSpec Ruby test framework. This nested style is usually called BDD style, referring to behavior-driven development.
你可以根据自己的喜好混合搭配这些风格(我就是这么做的)。当您可以轻松理解测试目标及其所有上下文时,您可以使用该test语法,而不会遇到太多麻烦。describe当您希望在同一场景下从同一入口点获得多个结果时,该语法会有所帮助。我在这里展示它们是因为我有时使用简洁的test风格,有时使用 -describe驱动的风格,具体取决于复杂性和表现力要求。
You can mix and match these styles as you like (I do). You can use the test syntax when it’s easy to understand your test target and all of its context, without going to too much trouble. The describe syntax can help when you’re expecting multiple results from the same entry point under the same scenario. I’m showing them both here because I sometimes use the terse test flavor and sometimes use the describe-driven flavor, depending on the complexity and expressiveness requirements.
既然有很多方法为了用 JavaScript 构建同样的东西,我想展示我们的设计的一些变化以及如果我们改变它会发生什么。假设我们想让密码验证器成为一个具有状态的对象。
Since there are many ways to build the same thing in JavaScript, I thought I’d show a couple of variations on our design and what happens if we change it. Suppose we’d like to make the password verifier an object with state.
将设计更改为有状态设计的原因之一可能是我打算让应用程序的不同部分使用此对象。一个部分将配置并向其添加规则,另一部分将使用它进行验证。另一个原因是我们需要知道如何处理有状态设计,并了解它将我们的测试拉向哪个方向,以及我们可以采取什么措施。
One reason to change the design into a stateful one might be that I intend for different parts of the application to use this object. One part will configure and add rules to it, and a different part will use it to do the verification. Another reason is that we need to know how to handle a stateful design and look at which directions it pulls our tests in, and what we can do about that.
Let’s look at the production code first.
Listing 2.10 Refactoring a function to a stateful class
类PasswordVerifier1 {
构造函数() {
this.rules = [];
}
addRule (规则) {
this.rules.push(rule);
}
验证(输入){
常量错误=[];
这。规则.forEach(规则=> {
const 结果 = 规则(输入);
if (结果.passed === false) {
错误.push(结果.原因);
}
});
返回错误;
}
}class PasswordVerifier1 {
constructor () {
this.rules = [];
}
addRule (rule) {
this.rules.push(rule);
}
verify (input) {
const errors = [];
this.rules.forEach(rule => {
const result = rule(input);
if (result.passed === false) {
errors.push(result.reason);
}
});
return errors;
}
}
我已经强调了清单 2.9 中的主要变化。这里并没有什么特别的地方,不过如果您有面向对象的背景,这可能会让您感觉更舒服。值得注意的是,这只是设计此功能的一种方法。我使用基于类的方法,以便可以展示此设计如何影响测试。
I’ve highlighted the main changes from listing 2.9. There’s nothing really special going on here, though this may feel more comfortable if you’re coming from an object-oriented background. It’s important to note that this is just one way to design this functionality. I’m using the class-based approach so that I can show how this design affects the test.
在这个新设计中,当前场景的入口点和出口点在哪里?想一想。工作单元的范围扩大了。要测试具有失败规则的场景,我们必须调用两个影响被测单元状态的函数:addRule和verify。
In this new design, where are the entry and exit points for the current scenario? Think about it for a second. The scope of the unit of work has increased. To test a scenario with a failing rule, we would have to invoke two functions that affect the state of the unit under test: addRule and verify.
现在让我们看看测试可能是什么样子(更改像往常一样突出显示)。
Now let’s see what the test might look like (changes are highlighted as usual).
Listing 2.11 Testing the stateful unit of work
描述('密码验证器', () => {
描述('有一个失败的规则', () => {
it('有一条基于规则的错误消息。原因', () => {
const 验证器 = new PasswordVerifier1();
const fakeRule = 输入 => ({ 传递: false,
原因:'假原因'});
verifier.addRule(fakeRule);
const error = verifier.verify('任意值');
Expect(errors[0]).toContain('假原因');
});
});
});describe('PasswordVerifier', () => {
describe('with a failing rule', () => {
it('has an error message based on the rule.reason', () => {
const verifier = new PasswordVerifier1();
const fakeRule = input => ({ passed: false,
reason: 'fake reason'});
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors[0]).toContain('fake reason');
});
});
});
到目前为止,一切都很好; 这里没有发生什么奇怪的事情。请注意,工作单元的表面有所增加。它现在涵盖两个必须协同工作的相关功能(addRule和verify)。由于设计的有状态性质,会发生耦合。我们需要使用两个函数来有效地进行测试,而不暴露对象的任何内部状态。
So far, so good; nothing fancy is happening here. Note that the surface of the unit of work has increased. It now spans two related functions that must work together (addRule and verify). There is a coupling that occurs due to the stateful nature of the design. We need to use two functions to test productively without exposing any internal state from the object.
测试本身看起来很无辜。但是,当我们想为同一场景编写多个测试时会发生什么?如果我们有多个退出点,或者如果我们想从同一退出点测试多个结果,就会发生这种情况。例如,假设我们想要验证是否只有一个错误。我们可以简单地在测试中添加一行,如下所示:
The test itself looks innocent enough. But what happens when we want to write several tests for the same scenario? That would happen if we have multiple exit points, or if we want to test multiple results from the same exit point. For example, let’s say we want to verify that we have only a single error. We could simply add a line to the test like this:
verifier.addRule(fakeRule);
const error = verifier.verify('任意值');
期望(errors.length).toBe(1); ❶expect
(errors[0]).toContain('假原因');verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors.length).toBe(1); ❶
expect(errors[0]).toContain('fake reason');
如果新断言失败会发生什么?第二个断言永远不会执行,因为测试运行程序将收到错误并继续执行下一个测试用例。
What happens if the new assertion fails? The second assertion would never execute, because the test runner would receive an error and move on to the next test case.
我们仍然想知道第二个断言是否会通过,对吧?因此,也许我们应该开始注释掉第一个并重新运行测试。这不是运行测试的健康方式。在 Gerard Meszaros 的书xUnit Test Patterns中,这种通过注释事物来测试其他事物的人类行为被称为断言轮盘赌。它可能会在测试运行中产生很多混乱和误报(认为某件事失败或通过,但事实并非如此)。
We’d still want to know if the second assertion would have passed, right? So maybe we’d start commenting out the first one and rerunning the test. That’s not a healthy way to run your tests. In Gerard Meszaros’ book xUnit Test Patterns, this human behavior of commenting things out to test other things is called assertion roulette. It can create lots of confusion and false positives in your test runs (thinking that something is failing or passing when it isn’t).
我宁愿将这个额外的检查分离到它自己的测试用例中,并命名一个好名字,如下所示。
I’d rather separate this extra check into its own test case with a good name, as follows.
Listing 2.12 Checking an extra end result from the same exit point
描述('密码验证器', () => {
描述('有一个失败的规则', () => {
it('有一条基于规则的错误消息。原因', () => {
const 验证器 = new PasswordVerifier1();
const fakeRule = 输入 => ({ 传递: false,
原因:'假原因'});
verifier.addRule(fakeRule);
const error = verifier.verify('任意值');
Expect(errors[0]).toContain('假原因');
});
it('只有一个错误', () => {
const verifier = new PasswordVerifier1();
const fakeRule = input => ({ Passed: false,
Reason: '假原因'});
verifier.addRule(fakeRule);
const error = verifier.verify('任意值');
期望(errors.length).toBe(1);
});
});
});describe('PasswordVerifier', () => {
describe('with a failing rule', () => {
it('has an error message based on the rule.reason', () => {
const verifier = new PasswordVerifier1();
const fakeRule = input => ({ passed: false,
reason: 'fake reason'});
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
const verifier = new PasswordVerifier1();
const fakeRule = input => ({ passed: false,
reason: 'fake reason'});
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors.length).toBe(1);
});
});
});
情况开始看起来很糟糕。是的,我们已经解决了断言轮盘赌问题。每个测试用例都it()可以单独失败,并且不会干扰其他测试用例的结果。但这花费了什么?一切。看看我们现在所有的重复。此时,那些有一些单元测试背景的人会开始对着书大喊:“使用setup/beforeEach方法!”
This is starting to look bad. Yes, we have solved the assertion roulette issue. Each it() can fail separately and not interfere with the results from the other test case. But what did it cost? Everything. Look at all the duplication we have now. At this point, those of you with some unit testing background will start shouting at the book: “Use a setup/beforeEach method!”
我还没介绍beforeEach()呢 这个函数和它的兄弟函数,afterEach(),用于设置和拆除测试用例所需的特定状态。还有beforeAll()和afterAll(),我尽量避免在单元测试场景中使用它。我们将在本书后面详细讨论这对兄弟姐妹。
I haven’t introduced beforeEach() yet. This function and its sibling, afterEach(), are used to set up and tear down a specific state required by the test cases. There’s also beforeAll() and afterAll(), which I try to avoid using at all costs for unit testing scenarios. We’ll talk more about the siblings later in the book.
beforeEach()可以帮助我们消除测试中的重复,因为它在describe我们嵌套它的块中的每个测试之前运行一次。我们还可以多次嵌套它,如下面的清单所示。
beforeEach() can help us remove duplication in our tests because it runs once before each test in the describe block in which we nest it. We can also nest it multiple times, as the following listing demonstrates.
Listing 2.13 Using beforeEach() on two levels
描述('PasswordVerifier', () => {
let verifier;
beforeEach(() => verifier = new PasswordVerifier1()); ❶
描述('有一个失败的规则', () => {
让 fakeRule,错误;
beforeEach(() => { ❷
fakeRule = input => ({passed: false, Reason: '假原因'});
verifier.addRule(fakeRule);
});
it('有一条基于规则的错误消息。原因', () => {
const error = verifier.verify('任意值');
Expect(errors[0]).toContain('假原因');
});
it('只有一个错误', () => {
const error = verifier.verify('任意值');
期望(errors.length).toBe(1);
});
});
});describe('PasswordVerifier', () => {
let verifier;
beforeEach(() => verifier = new PasswordVerifier1()); ❶
describe('with a failing rule', () => {
let fakeRule, errors;
beforeEach(() => { ❷
fakeRule = input => ({passed: false, reason: 'fake reason'});
verifier.addRule(fakeRule);
});
it('has an error message based on the rule.reason', () => {
const errors = verifier.verify('any value');
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
const errors = verifier.verify('any value');
expect(errors.length).toBe(1);
});
});
});
❶ Setting up a new verifier that will be used in each test
❷ Setting up a fake rule that will be used within this describe() method
Look at all that extracted code.
在第一个中beforeEach(),我们正在设置一个PasswordVerifier1将为每个测试用例创建的新测试用例。之后beforeEach(),我们将设置一个虚假规则,并将其添加到特定场景下每个测试用例的新验证程序中。如果我们有其他场景,第 6 行中的第二个场景beforeEach()将不会运行,但第一个场景会运行。
In the first beforeEach(), we’re setting up a new PasswordVerifier1 that will be created for each test case. In the beforeEach() after that, we’re setting up a fake rule and adding it to the new verifier for every test case under that specific scenario. If we had other scenarios, the second beforeEach() in line 6 wouldn’t run for them, but the first one would.
现在测试看起来更短,这正是您在测试中想要的,以使其更具可读性和可维护性。我们从每个测试中删除了创建行并重用了相同的高级变量verifier。
The tests seem shorter now, which ideally is what you want in a test, to make it more readable and maintainable. We removed the creation line from each test and reused the same higher-level variable verifier.
There are a couple of caveats:
We forgot to reset the errors array in beforeEach() on line 6. That could bite us later on.
Jest 默认情况下并行运行单元测试。这意味着将验证器移至第 2 行可能会导致并行测试出现问题,其中验证器可能会被并行运行中的不同测试覆盖,这会破坏正在运行的测试的状态。Jest 与我所知道的大多数其他语言中的单元测试框架有很大不同,它强调在单个线程中运行测试,而不是并行(至少默认情况下),以避免此类问题。对于 Jest,我们必须记住并行测试是现实的,因此具有共享上层状态的有状态测试(就像我们在第 2 行中所做的那样)可能会出现问题,并导致不稳定的测试因未知原因而失败。
Jest runs unit tests in parallel by default. This means that moving the verifier to line 2 may cause an issue with parallel tests, where the verifier could be overwritten by a different test on a parallel run, which would screw up the state of our running test. Jest is quite different from unit test frameworks in most other languages I know, which make a point of running tests in a single thread, not in parallel (at least by default), to avoid such issues. With Jest, we have to remember that parallel tests are a reality, so stateful tests with a shared upper state, like we have at line 2, can potentially be problematic and cause flaky tests that fail for unknown reasons.
We’ll correct both of these issues soon.
We lost a couple of things in the process of refactoring to beforeEach():
If I’m trying to read only the it() parts, I can’t tell where the verifier is created and declared. I’d have to scroll up to understand.
The same goes for understanding what rule was added. I’d have to look one level above the it() to see what rule was added, or look up the describe() block description.
现在看来,这似乎并没有那么糟糕。但稍后我们会看到,随着场景列表大小的增加,这种结构开始变得有点复杂。较大的文件会带来我所说的滚动疲劳,要求测试读者上下滚动测试文件以了解测试的上下文和状态。这使得维护和阅读测试成为一件苦差事,而不是简单的阅读行为。
Right now, this doesn’t seem so bad. But we’ll see later that this structure starts to get a bit hairy as the scenario list increases in size. Larger files can bring about what I like to call scroll fatigue, requiring the test reader to scroll up and down the test file to understand the context and state of the tests. This makes maintaining and reading the tests a chore instead of a simple act of reading.
这种嵌套对于报告来说非常有用,但对于必须不断查找某些东西来自哪里的人类来说却很糟糕。如果您曾经尝试在浏览器的检查器窗口中调试 CSS 样式,您就会知道这种感觉。您会看到某个特定单元格由于某种原因以粗体显示。然后向上滚动以查看哪种样式使第三个节点下的<div>特殊嵌套单元格内的样式变为粗体。table
This nesting is great for reporting, but it sucks for humans who have to keep looking up where something came from. If you’ve ever tried to debug CSS styles in the browser’s inspector window, you’ll know the feeling. You’ll see that a specific cell is bold for some reason. Then you scroll up to see which style made that <div> inside nested cells in a special table under the third node bold.
让我们看看当我们在下面的列表中更进一步时会发生什么。由于我们正在删除重复项,因此我们还可以调用verify并beforeEach()从每个it(). 这基本上是将 AAA 模式中的编曲和表演部分放入函数中beforeEach()。
Let’s see what happens when we take it one step further in the following listing. Since we’re in the process of removing duplication, we can also call verify in beforeEach() and remove an extra line from each it(). This is basically putting the arrange and act parts from the AAA pattern into the beforeEach() function.
清单 2.14 将排列和表演部分推入beforeEach()
Listing 2.14 Pushing the arrange and act parts into beforeEach()
描述('密码验证器', () => {
让验证者;
beforeEach(() => 验证器 = new PasswordVerifier1());
描述('有一个失败的规则', () => {
让 fakeRule,错误;
之前(()=> {
fakeRule = input => ({passed: false, Reason: '假原因'});
verifier.addRule(fakeRule);
错误= verifier.verify('任何值');
});
it('有一条基于规则的错误消息。原因', () => {
Expect(errors[0]).toContain('假原因');
});
it('只有一个错误', () => {
期望(errors.length).toBe(1);
});
});
});describe('PasswordVerifier', () => {
let verifier;
beforeEach(() => verifier = new PasswordVerifier1());
describe('with a failing rule', () => {
let fakeRule, errors;
beforeEach(() => {
fakeRule = input => ({passed: false, reason: 'fake reason'});
verifier.addRule(fakeRule);
errors = verifier.verify('any value');
});
it('has an error message based on the rule.reason', () => {
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
expect(errors.length).toBe(1);
});
});
});
代码重复已减少到最低限度,但现在errors如果我们想了解每个it().
The code duplication has been reduced to a minimum, but now we also need to look up where and how we got the errors array if we want to understand each it().
让我们加倍努力,添加一些更基本的场景,看看这种方法是否可以随着问题空间的增加而扩展。
Let’s double down and add a few more basic scenarios, and see if this approach is scalable as the problem space increases.
Listing 2.15 Adding extra scenarios
描述('v6 密码验证器', () => {
让验证者;
beforeEach(() => 验证器 = new PasswordVerifier1());
描述('有一个失败的规则', () => {
让 fakeRule,错误;
之前(()=> {
fakeRule = input => ({passed: false, Reason: '假原因'});
verifier.addRule(fakeRule);
错误= verifier.verify('任何值');
});
it('有一条基于规则的错误消息。原因', () => {
Expect(errors[0]).toContain('假原因');
});
it('只有一个错误', () => {
期望(errors.length).toBe(1);
});
});
描述('带有传递规则',()=> {
let fakeRule,errors;
beforeEach(()=> {
fakeRule = input =>({passed:true,reason:''});
verifier.addRule(fakeRule) ;
error = verifier.verify('任何值');
});
it('没有错误', () => {
Expect(errors.length).toBe(0);
});
});
描述('具有失败和通过规则',()=> {
let fakeRulePass,fakeRuleFail,errors;
beforeEach(()=> {
fakeRulePass = input =>({passed:true,reason:'假成功'}) ;
fakeRuleFail = input => ({passed: false, Reason: '假原因'});
verifier.addRule(fakeRulePass);
verifier.addRule(fakeRuleFail);
errors = verifier.verify('任意值');
});
it('有一个错误', () => {
Expect(errors.length).toBe(1);
});
it('错误文本属于失败的规则', () => {
Expect(errors[0] ).toContain('假原因');
});
});
});describe('v6 PasswordVerifier', () => {
let verifier;
beforeEach(() => verifier = new PasswordVerifier1());
describe('with a failing rule', () => {
let fakeRule, errors;
beforeEach(() => {
fakeRule = input => ({passed: false, reason: 'fake reason'});
verifier.addRule(fakeRule);
errors = verifier.verify('any value');
});
it('has an error message based on the rule.reason', () => {
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
expect(errors.length).toBe(1);
});
});
describe('with a passing rule', () => {
let fakeRule, errors;
beforeEach(() => {
fakeRule = input => ({passed: true, reason: ''});
verifier.addRule(fakeRule);
errors = verifier.verify('any value');
});
it('has no errors', () => {
expect(errors.length).toBe(0);
});
});
describe('with a failing and a passing rule', () => {
let fakeRulePass,fakeRuleFail, errors;
beforeEach(() => {
fakeRulePass = input => ({passed: true, reason: 'fake success'});
fakeRuleFail = input => ({passed: false, reason: 'fake reason'});
verifier.addRule(fakeRulePass);
verifier.addRule(fakeRuleFail);
errors = verifier.verify('any value');
});
it('has one error', () => {
expect(errors.length).toBe(1);
});
it('error text belongs to failed rule', () => {
expect(errors[0]).toContain('fake reason');
});
});
});
Do we like this? I don’t. Now we’re seeing a couple of extra problems:
I can already start to see lots of repetition in the beforeEach() parts.
The potential for scroll fatigue has increased dramatically, with more options of which beforeEach() affects which it() state.
在实际项目中,beforeEach()函数往往是测试文件的垃圾箱。人们将各种测试初始化的东西扔在那里:只有某些测试需要的东西,影响所有其他测试的东西,以及没有人再使用的东西。把东西放在最容易的地方是人的本性,尤其是如果你之前的其他人也这样做的话。
In real projects, beforeEach() functions tend to be the garbage bin of the test file. People throw all kinds of test-initialized stuff in there: things that only some tests need, things that affect all the other tests, and things that nobody uses anymore. It’s human nature to put things in the easiest place possible, especially if everyone else before you has done so as well.
我对这种方法并不着迷beforeEach()。让我们看看是否可以缓解其中一些问题,同时仍将重复保持在最低限度。
I’m not crazy about the beforeEach() approach. Let’s see if we can mitigate some of these issues while still keeping duplication to a minimum.
工厂方法是简单的辅助函数,可以帮助我们构建对象或特殊状态,并在多个地方重用相同的逻辑。也许我们可以通过使用清单 2.16 中的失败和通过规则的几个工厂方法来减少一些重复和笨重的代码。
Factory methods are simple helper functions that help us build objects or special states and reuse the same logic in multiple places. Perhaps we can reduce some of the duplication and clunky-feeling code by using a couple of factory methods for the failing and passing rules in listing 2.16.
Listing 2.16 Adding a couple of factory methods to the mix
描述('密码验证器', () => {
让验证者;
beforeEach(() => 验证器 = new PasswordVerifier1());
描述('有一个失败的规则', () => {
让错误;
之前(()=> {
verifier.addRule( makeFailingRule('假原因') );
错误= verifier.verify('任何值');
});
it('有一条基于规则的错误消息。原因', () => {
Expect(errors[0]).toContain('假原因');
});
it('只有一个错误', () => {
期望(errors.length).toBe(1);
});
});
描述('具有传递规则',()=> {
让错误;
之前(()=> {
verifier.addRule( makePassingRule() );
错误= verifier.verify('任何值');
});
it('没有错误', () => {
期望(errors.length).toBe(0);
});
});
描述('失败和通过规则', () => {
让错误;
之前(()=> {
verifier.addRule( makePassingRule() );
verifier.addRule( makeFailingRule('假原因') );
错误= verifier.verify('任何值');
});
it('有一个错误', () => {
期望(errors.length).toBe(1);
});
it('错误文本属于失败的规则', () => {
Expect(errors[0]).toContain('假原因');
});
});
。。。
const makeFailingRule = (原因) => {
return (输入) => {
return { 通过: false, 原因: 原因 };
};
};
const makePassingRule = () => (输入) => {
return { 通过: true, 原因: '' };
};
})describe('PasswordVerifier', () => {
let verifier;
beforeEach(() => verifier = new PasswordVerifier1());
describe('with a failing rule', () => {
let errors;
beforeEach(() => {
verifier.addRule(makeFailingRule('fake reason'));
errors = verifier.verify('any value');
});
it('has an error message based on the rule.reason', () => {
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
expect(errors.length).toBe(1);
});
});
describe('with a passing rule', () => {
let errors;
beforeEach(() => {
verifier.addRule(makePassingRule());
errors = verifier.verify('any value');
});
it('has no errors', () => {
expect(errors.length).toBe(0);
});
});
describe('with a failing and a passing rule', () => {
let errors;
beforeEach(() => {
verifier.addRule(makePassingRule());
verifier.addRule(makeFailingRule('fake reason'));
errors = verifier.verify('any value');
});
it('has one error', () => {
expect(errors.length).toBe(1);
});
it('error text belongs to failed rule', () => {
expect(errors[0]).toContain('fake reason');
});
});
. . .
const makeFailingRule = (reason) => {
return (input) => {
return { passed: false, reason: reason };
};
};
const makePassingRule = () => (input) => {
return { passed: true, reason: '' };
};
})
和工厂方法让我们的功能更加清晰一些makeFailingRule()。makePassingRule()beforeEach()
The makeFailingRule() and makePassingRule() factory methods have made our beforeEach() functions a little more clear.
如果我们根本不使用它beforeEach()来初始化各种东西怎么办?如果我们改用小工厂方法会怎样?让我们看看它是什么样子的。
What if we don’t use beforeEach() to initialize various things at all? What if we switched to using small factory methods instead? Let’s see what that looks like.
Listing 2.17 Replacing beforeEach() with factory methods
const makeVerifier = () => new PasswordVerifier1();
const passingRule = (输入) => ({已通过: true, 原因: ''});
const makeVerifierWithPassingRule = () => {
const verifier = makeVerifier();
verifier.addRule(passingRule);
返回验证者;
};
const makeVerifierWithFailedRule = (原因) => {
const verifier = makeVerifier();
const fakeRule = 输入 => ({已通过: false, 原因: 原因});
verifier.addRule(fakeRule);
返回验证者;
};
描述('密码验证器', () => {
描述('有一个失败的规则', () => {
it('有一条基于规则的错误消息。原因', () => {
const verifier = makeVerifierWithFailedRule('假原因');
const error = verifier.verify('任何输入');
Expect(errors[0]).toContain( '假原因' );
});
it('只有一个错误', () => {
const verifier = makeVerifierWithFailedRule('假原因');
const error = verifier.verify('任何输入');
期望(errors.length).toBe(1);
});
});
描述('具有传递规则',()=> {
it('没有错误', () => {
const 验证器 = makeVerifierWithPassingRule();
const error = verifier.verify('任何输入');
期望(errors.length).toBe(0);
});
});
描述('失败和通过规则', () => {
it('有一个错误', () => {
const verifier = makeVerifierWithFailedRule('假原因');
verifier.addRule(passingRule);
const error = verifier.verify('任何输入');
期望(errors.length).toBe(1);
});
it('错误文本属于失败的规则', () => {
const verifier = makeVerifierWithFailedRule('假原因');
verifier.addRule(passingRule);
const error = verifier.verify('任何输入');
Expect(errors[0]).toContain( '假原因' );
});
});
});const makeVerifier = () => new PasswordVerifier1();
const passingRule = (input) => ({passed: true, reason: ''});
const makeVerifierWithPassingRule = () => {
const verifier = makeVerifier();
verifier.addRule(passingRule);
return verifier;
};
const makeVerifierWithFailedRule = (reason) => {
const verifier = makeVerifier();
const fakeRule = input => ({passed: false, reason: reason});
verifier.addRule(fakeRule);
return verifier;
};
describe('PasswordVerifier', () => {
describe('with a failing rule', () => {
it('has an error message based on the rule.reason', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
const errors = verifier.verify('any input');
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
const errors = verifier.verify('any input');
expect(errors.length).toBe(1);
});
});
describe('with a passing rule', () => {
it('has no errors', () => {
const verifier = makeVerifierWithPassingRule();
const errors = verifier.verify('any input');
expect(errors.length).toBe(0);
});
});
describe('with a failing and a passing rule', () => {
it('has one error', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
const errors = verifier.verify('any input');
expect(errors.length).toBe(1);
});
it('error text belongs to failed rule', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
const errors = verifier.verify('any input');
expect(errors[0]).toContain('fake reason');
});
});
});
这里的长度与清单 2.16 中的长度大致相同,但我发现代码更具可读性,因此更容易维护。我们取消了这些beforeEach()功能,但并没有失去可维护性。我们消除的重复量可以忽略不计,但由于删除了嵌套beforeEach()块,可读性大大提高。
The length here is about the same as in listing 2.16, but I find the code to be more readable and thus more easily maintained. We’ve eliminated the beforeEach() functions, but we didn’t lose maintainability. The amount of repetition we’ve eliminated is negligible, but the readability has improved greatly due to the removal of the nested beforeEach() blocks.
此外,我们还降低了滚动疲劳的风险。作为测试的读者,我不必上下滚动文件来查找对象何时创建或声明。我可以从 中收集所有信息it()。我们不需要知道某些东西是如何创建的,但我们知道它何时创建以及使用哪些重要参数进行初始化。一切都得到明确解释。
Furthermore, we’ve reduced the risk of scroll fatigue. As a reader of the test, I don’t have to scroll up and down the file to find out when an object is created or declared. I can glean all the information from the it(). We don’t need to know how something is created, but we know when it is created and what important parameters it is initialized with. Everything is explicitly explained.
如果需要,我可以深入研究特定的工厂方法,并且我喜欢每个方法it()都封装自己的状态。嵌套describe()结构是了解我们所在位置的好方法,但状态都是从it()块内部触发的,而不是在块外部触发。
If the need arises, I can drill into specific factory methods, and I like that each it() is encapsulating its own state. The nested describe() structure is a good way to know where we are, but the state is all triggered from inside the it() blocks, not outside of them.
清单 2.17 中的测试足够自我封装,这些describe()块仅充当理解的附加糖。如果我们不需要它们,就不再需要它们。如果我们愿意,我们可以编写如下所示的测试。
The tests in listing 2.17 are self-encapsulated enough that the describe() blocks act only as added sugar for understanding. They are no longer needed if we don’t want them. If we wanted to, we could write the tests as in the following listing.
Listing 2.18 Removing nested describes
test('通过验证器,规则失败,' +
'根据规则有错误消息。reason', () => {
const verifier = makeVerifierWithFailedRule('假原因');
const error = verifier.verify('任何输入');
Expect(errors[0]).toContain('假原因');
});
test('通过验证器,但规则失败,只有一个错误', () => {
const verifier = makeVerifierWithFailedRule('假原因');
const error = verifier.verify('任何输入');
期望(errors.length).toBe(1);
});
test('通过验证器,有通过规则,没有错误', () => {
const 验证器 = makeVerifierWithPassingRule();
const error = verifier.verify('任何输入');
期望(errors.length).toBe(0);
});
test('通过验证器,有通过和失败规则,' +
' 有一个错误', () => {
const verifier = makeVerifierWithFailedRule('假原因');
verifier.addRule(passingRule);
const error = verifier.verify('任何输入');
期望(errors.length).toBe(1);
});
test('通过验证器,有通过和失败规则,' +
'错误文本属于失败规则', () => {
const verifier = makeVerifierWithFailedRule('假原因');
verifier.addRule(passingRule);
const error = verifier.verify('任何输入');
Expect(errors[0]).toContain('假原因');
});test('pass verifier, with failed rule, ' +
'has an error message based on the rule.reason', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
const errors = verifier.verify('any input');
expect(errors[0]).toContain('fake reason');
});
test('pass verifier, with failed rule, has exactly one error', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
const errors = verifier.verify('any input');
expect(errors.length).toBe(1);
});
test('pass verifier, with passing rule, has no errors', () => {
const verifier = makeVerifierWithPassingRule();
const errors = verifier.verify('any input');
expect(errors.length).toBe(0);
});
test('pass verifier, with passing and failing rule,' +
' has one error', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
const errors = verifier.verify('any input');
expect(errors.length).toBe(1);
});
test('pass verifier, with passing and failing rule,' +
' error text belongs to failed rule', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
const errors = verifier.verify('any input');
expect(errors[0]).toContain('fake reason');
});
工厂方法为我们提供了所需的所有功能,同时又不会失去每个特定测试的清晰度。
The factory methods provide us with all the functionality we need, without losing clarity for each specific test.
我有点喜欢清单 2.18 的简洁性。这很容易理解。我们可能会在这里失去一些结构清晰度,因此在某些情况下我会使用 -lessdescribe方法,并且在某些地方嵌套describe会使内容更具可读性。您的项目的可维护性和可读性的最佳点可能位于这两点之间。
I kind of like the terseness of listing 2.18. It’s easy to understand. We might lose a bit of structure clarity here, so there are instances where I go with the describe-less approach, and there are places where nested describes make things more readable. The sweet spot of maintainability and readability for your project is probably somewhere between these two points.
让我们离开类verifier来为验证器创建和测试新的自定义规则。清单 2.19 显示了一个针对大写字母的简单规则(我意识到具有这些要求的密码不再被认为是一个好主意,但出于演示目的,我同意它)。
Let’s move away from the verifier class to work on creating and testing a new custom rule for the verifier. Listing 2.19 shows a simple rule for an uppercase letter (I realize passwords with these requirements are no longer considered a great idea, but for demonstration purposes I’m okay with it).
const oneUpperCaseRule = (输入) => {
返回 {
通过:(input.toLowerCase()!==输入),
原因:“至少需要一个大写字母”
};
};const oneUpperCaseRule = (input) => {
return {
passed: (input.toLowerCase() !== input),
reason: 'at least one upper case needed'
};
};
We could write a couple of tests as in the following listing.
Listing 2.20 Testing a rule with variations
描述('一个大写规则', function () {
test('如果没有大写,则失败', () => {
const 结果 = oneUpperCaseRule('abc');
期望(结果。通过)。toEqual(假);
});
test('给定一个大写字母,它通过', () => {
const result = oneUpperCaseRule('Abc');
Expect(result.passed).toEqual(true);
});
test('给出不同的大写字母,它通过', () => {
const result = oneUpperCaseRule('aBc');
Expect(result.passed).toEqual(true);
});
});describe('one uppercase rule', function () {
test('given no uppercase, it fails', () => {
const result = oneUpperCaseRule('abc');
expect(result.passed).toEqual(false);
});
test('given one uppercase, it passes', () => {
const result = oneUpperCaseRule('Abc');
expect(result.passed).toEqual(true);
});
test('given a different uppercase, it passes', () => {
const result = oneUpperCaseRule('aBc');
expect(result.passed).toEqual(true);
});
});
在清单 2.20 中,我强调了如果我们尝试相同的场景,但工作单元的输入略有变化,则可能会出现一些重复。在本例中,我们想要测试大写字母在哪里并不重要,只要它在那里就行。但是,如果我们想要更改大写逻辑,或者如果我们需要以某种方式纠正该用例的断言,这种重复将会伤害我们。
In listing 2.20 I highlighted some duplication we might have if we’re trying out the same scenario with small variations in the input to the unit of work. In this case, we want to test that it should not matter where the uppercase letter is, as long as it’s there. But this duplication will hurt us down the road if we ever want to change the uppercase logic, or if we need to correct the assertions in some way for that use case.
在 JavaScript 中创建参数化测试有几种方法,Jest 已经包含了一种内置方法:(test.each也别名为it.each)。下一个清单显示了我们如何使用此功能来删除测试中的重复项。
There are a few ways to create parameterized tests in JavaScript, and Jest already includes one that’s built in: test.each (also aliased to it.each). The next listing shows how we could use this feature to remove duplication in our tests.
描述('一个大写规则', () => {
test('如果没有大写,则失败', () => {
const 结果 = oneUpperCaseRule('abc');
期望(结果。通过)。toEqual(假);
});
test.each(['Abc', ❶
'aBc']) ❶
('给定一个大写字母,它通过', (input) => { ❷
const result = oneUpperCaseRule(input);
expect(result.passed).toEqual(真的);
});
});describe('one uppercase rule', () => {
test('given no uppercase, it fails', () => {
const result = oneUpperCaseRule('abc');
expect(result.passed).toEqual(false);
});
test.each(['Abc', ❶
'aBc']) ❶
('given one uppercase, it passes', (input) => { ❷
const result = oneUpperCaseRule(input);
expect(result.passed).toEqual(true);
});
});
❶ Passing in an array of values that are mapped to the input parameter
❷ Using each input parameter passed in the array
在此示例中,测试将对数组中的每个值重复一次。一开始有点拗口,但是一旦你尝试过这种方法,它就会变得很容易使用。它也非常可读。
In this example, the test will repeat once for each value in the array. It’s a bit of a mouthful at first, but once you’ve tried this approach, it becomes easy to use. It’s also pretty readable.
如果我们想传递多个参数,我们可以将它们放在一个数组中,如下清单所示。
If we want to pass in multiple parameters, we can enclose them in an array, as in the following listing.
Listing 2.22 Refactoring test.each
describe('一个大写规则', () => {
test.each([ ['Abc', true], ❶
['aBc', true],
['abc', false]]) ❷
('给定 %s , %s ', (输入,预期) => { ❸
const 结果 = oneUpperCaseRule(输入);
期望(结果.通过).toEqual(预期);
});
});describe('one uppercase rule', () => {
test.each([ ['Abc', true], ❶
['aBc', true],
['abc', false]]) ❷
('given %s, %s ', (input, expected) => { ❸
const result = oneUpperCaseRule(input);
expect(result.passed).toEqual(expected);
});
});
❶ Providing three arrays, each with two parameters
❷ A new false expectation for a missing uppercase character
❸ Jest maps the array values to arguments automatically.
不过,我们不必使用 Jest。JavaScript 具有足够的通用性,如果我们愿意的话,我们可以很容易地推出自己的参数化测试。
We don’t have to use Jest, though. JavaScript is versatile enough to allow us to roll out our own parameterized test quite easily if we want to.
Listing 2.23 Using a vanilla JavaScript for
描述('一个大写规则,使用普通JS',()=> {
const测试= {
'Abc':true,
'aBc':true,
'abc':false,
};
for (const [输入,预期] of Object.entries(tests)) {
test('给定${input}, ${expected} ', () => {
const 结果 = oneUpperCaseRule(输入);
期望(结果.通过).toEqual(期望);
});
}
});describe('one uppercase rule, with vanilla JS for', () => {
const tests = {
'Abc': true,
'aBc': true,
'abc': false,
};
for (const [input, expected] of Object.entries(tests)) {
test('given ${input}, ${expected}', () => {
const result = oneUpperCaseRule(input);
expect(result.passed).toEqual(expected);
});
}
});
这取决于你想使用哪一个(我喜欢保持简单并使用test.each)。关键是,Jest 只是一个工具。参数化测试的模式可以通过多种方式实现。这种模式给了我们很大的权力,但也给了我们很大的责任。滥用这种技术并创建更难理解的测试确实很容易。
It’s up to you which one you want to use (I like to keep it simple and use test.each). The point is, Jest is just a tool. The pattern of parameterized tests can be implemented in multiple ways. This pattern gives us a lot of power, but also a lot of responsibility. It’s really easy to abuse this technique and create tests that are harder to understand.
我通常尝试确保相同的场景(输入类型)适用于整个表。如果我在代码审查中审查这个测试,我会告诉编写它的人这个测试实际上是在测试两种不同的场景:一个没有大写,另一个有一个大写。我会将它们分成两个不同的测试。
I usually try to make sure that the same scenario (type of input) holds for the entire table. If I were reviewing this test in a code review, I would have told the person who wrote it that this test is actually testing two different scenarios: one with no uppercase, and a couple with one uppercase. I would split those out into two different tests.
在这个例子中,我想表明,摆脱许多测试并将它们全部放在一个大的测试中是非常容易的test.each——即使这会损害可读性——所以在使用这些特定的剪刀运行时要小心。
In this example, I wanted to show that it’s very easy to get rid of many tests and put them all in a big test.each—even when it hurts readability—so be careful when running with these specific scissors.
有时我们需要设计一段代码,在正确的时间使用正确的数据抛出错误。如果我们将代码添加到verify函数中,如果没有配置规则,则抛出错误,会发生什么情况,如下一个清单所示?
Sometimes we need to design a piece of code that throws an error at the right time with the right data. What happens if we add code to the verify function that throws an error if there are no rules configured, as in the next listing?
Listing 2.24 Throwing an error
verify (input) {
if (this.rules.length === 0) {
throw new Error('没有配置规则');
}
。。。verify (input) {
if (this.rules.length === 0) {
throw new Error('There are no rules configured');
}
. . .
try我们可以使用/以老式的方式测试它,如果没有出现错误,catch则测试失败。
We could test it the old-fashioned way by using try/catch, and failing the test if we don’t get an error.
Listing 2.25 Testing exceptions with try/catch
test('验证,无规则,抛出异常', () => {
const 验证器 = makeVerifier();
尝试 {
verifier.verify('任何输入');
fail('错误是预期的,但没有抛出');
} catch (e) {
Expect(e.message).toContain('没有配置规则');
}
});test('verify, with no rules, throws exception', () => {
const verifier = makeVerifier();
try {
verifier.verify('any input');
fail('error was expected but not thrown');
} catch (e) {
expect(e.message).toContain('no rules configured');
}
});
此try/catch模式是一种有效的方法,但打字非常冗长且烦人。Jest 与大多数其他框架一样,包含一个快捷方式来完成此类场景,使用expect().toThrowError().
This try/catch pattern is an effective method but very verbose and annoying to type. Jest, like most other frameworks, contains a shortcut to accomplish exactly this type of scenario, using expect().toThrowError().
清单 2.26 使用expect().toThrowError()
Listing 2.26 Using expect().toThrowError()
test('验证,无规则,抛出异常', () => {
const 验证器 = makeVerifier();
Expect(() => verifier.verify('任何输入') )
.toThrowError(/没有配置规则/); ❶
});test('verify, with no rules, throws exception', () => {
const verifier = makeVerifier();
expect(() => verifier.verify('any input'))
.toThrowError(/no rules configured/); ❶
});
❶ Using a regular expression instead of looking for the exact string
请注意,我使用正则表达式匹配来检查错误字符串是否包含特定字符串,并且不等于它,以便在字符串两侧发生变化时使测试更加面向未来。toThrowError有一些变体,您可以访问https://jestjs.io/了解所有相关信息。
Notice that I’m using a regular expression match to check that the error string contains a specific string, and is not equal to it, so as to make the test a bit more future-proof if the string changes on its sides. toThrowError has a few variations, and you can go to https://jestjs.io/ find out all about them.
如果您只想运行特定类别的测试,例如仅单元测试,或仅集成测试,或仅涉及应用程序特定部分的测试,Jest 目前无法定义测试用例类别。
If you’d like to run only a specific category of tests, such as only unit tests, or only integration tests, or only tests that touch a specific part of the application, Jest currently doesn’t have the ability to define test case categories.
不过,一切并没有失去。Jest 有一个特殊的--testPathPattern命令行标志,它允许我们定义 Jest 如何找到我们的测试。我们可以使用不同的路径来触发此命令,以执行我们想要运行的特定类型的测试(例如“‘集成’文件夹下的所有测试”)。您可以在https://jestjs.io/docs/en/cli获取完整详细信息。
All is not lost, though. Jest has a special --testPathPattern command-line flag, which allows us to define how Jest will find our tests. We can trigger this command with a different path for a specific type of test we’d like to run (such as “all tests under the ‘integration’ folder”). You can get the full details at https://jestjs.io/docs/en/cli.
另一种选择是为每个测试类别创建一个单独的 jest.config.js 文件,每个测试类别都有自己的testRegex配置和其他属性。
Another alternative is to create a separate jest.config.js file for each test category, each with its own testRegex configuration and other properties.
清单 2.27 创建单独的 jest.config.js 文件
Listing 2.27 Creating separate jest.config.js files
// jest.config.integration.js
var config = require('./jest.config')
config.testRegex = "集成\\.js$"
模块.exports = 配置
// jest.config.unit.js
var config = require('./jest.config')
config.testRegex = "unit\\.js$"
模块.exports = 配置// jest.config.integration.js
var config = require('./jest.config')
config.testRegex = "integration\\.js$"
module.exports = config
// jest.config.unit.js
var config = require('./jest.config')
config.testRegex = "unit\\.js$"
module.exports = config
然后,对于每个类别,您可以创建一个单独的 npm 脚本,该脚本使用自定义配置文件调用 Jest 命令行:jest -c my.custom.jest.config.js.
Then, for each category, you can create a separate npm script that invokes the Jest command line with a custom config file: jest -c my.custom.jest.config.js.
Listing 2.28 Using separate npm scripts
//包.json
。。。
“脚本”:{
"unit": "jest -c jest.config.unit.js",
"integ": "jest -c jest.config.integration.js"
。。。//Package.json
. . .
"scripts": {
"unit": "jest -c jest.config.unit.js",
"integ": "jest -c jest.config.integration.js"
. . .
在下一章中,我们将研究具有依赖性和可测试性问题的代码,并且我们将开始讨论伪造、间谍、模拟和桩的概念,以及如何使用它们针对此类代码编写测试。
In the next chapter, we’ll look at code that has dependencies and testability problems, and we’ll start discussing the idea of fakes, spies, mocks, and stubs, and how you can use them to write tests against such code.
Jest 是一个流行的、开源的 JavaScript 应用程序测试框架。它同时充当编写测试时使用的测试库、用于在测试内部进行断言的断言库、测试运行器和测试报告器。
Jest is a popular, open source test framework for JavaScript applications. It simultaneously acts as a test library to use when writing tests, an assertion library for asserting inside the tests, a test runner, and a test reporter.
Arrange-Act-Assert ( AAA ) 是一种流行的结构化测试模式。它为所有测试提供了简单、统一的布局。一旦习惯了,您就可以轻松阅读和理解任何测试。
Arrange-Act-Assert (AAA) is a popular pattern for structuring tests. It provides a simple, uniform layout for all tests. Once you get used to it, you can easily read and understand any test.
在 AAA 模式中,安排部分是您将被测系统及其依赖项置于所需状态的地方。在act部分中,您调用方法,传递准备好的依赖项,并捕获输出值(如果有)。在断言部分,您验证结果。
In the AAA pattern, the arrange section is where you bring the system under test and its dependencies to a desired state. In the act section, you call methods, pass the prepared dependencies, and capture the output value (if any). In the assert section, you verify the outcome.
命名测试的一个好模式是在测试名称中包含被测工作单元、单元的场景或输入以及预期的行为或退出点。此模式的一个方便的助记符是 USE(单位、场景、期望)。
A good pattern for naming tests is to include in the name of the test the unit of work under test, the scenario or inputs to the unit, and the expected behavior or exit point. A handy mnemonic for this pattern is USE (unit, scenario, expectation).
Jest 提供了多个函数,有助于围绕多个相关测试创建更多结构。describe()是一个作用域函数,允许将多个测试(或测试组)分组在一起。一个很好的比喻describe()是包含测试或其他文件夹的文件夹。test()是表示单个测试的函数。it()是 的别名test(),但与 结合使用时可提供更好的可读性describe()。
Jest provides several functions that help create more structure around multiple related tests. describe() is a scoping function that allows for grouping multiple tests (or groups of tests) together. A good metaphor for describe() is a folder containing tests or other folders. test() is a function denoting an individual test. it() is an alias for test(), but it provides better readability when used in combination with describe().
beforeEach() helps avoid duplication by extracting code that is common for the nested describe and it functions.
The use of beforeEach() often leads to scroll fatigue, when you have to look at various places to understand what a test does.
Factory methods with plain tests (without any beforeEach()) improve readability and help avoid scroll fatigue.
Parameterized tests help reduce the amount of code needed for similar tests. The drawback is that the tests become less readable as you make them more generic.
To maintain a balance between test readability and code reuse, only parameterize input values. Create separate tests for different output values.
Jest 不支持测试类别,但您可以使用该--testPathPattern标志运行测试组。testRegex也可以在配置文件中设置。
Jest doesn’t support test categories, but you can run groups of tests using the --testPathPattern flag. You can also set up testRegex in the configuration file.
在第 1 部分介绍了基础知识后,我现在将介绍在现实世界中编写测试所需的核心测试和重构技术。
Having covered the basics in part 1, I’ll now introduce the core testing and refactoring techniques necessary for writing tests in the real world.
在第 3 章中,我们将研究桩以及它们如何帮助打破依赖关系。我们将介绍使代码更易于测试的重构技术,并且您将在此过程中了解接缝。
In chapter 3, we’ll examine stubs and how they help break dependencies. We’ll go over refactoring techniques that make code more testable, and you’ll learn about seams in the process.
在第 4 章中,我们将继续讨论模拟对象和交互测试,我们将了解模拟对象与桩的不同之处,并且我们将探讨伪造的概念。
In chapter 4, we’ll move on to mock objects and interaction testing, we’ll look at how mock objects differ from stubs, and we’ll explore the concept of fakes.
在第 5 章中,我们将介绍隔离框架(也称为模拟框架),以及它们如何解决手写模拟和桩中涉及的一些重复编码。第 6 章讨论异步代码,例如 Promise、计时器和事件,以及测试此类代码的各种方法。
In chapter 5, we’ll look at isolation frameworks, also known as mocking frameworks, and at how they solve some of the repetitive coding involved in handwritten mocks and stubs. Chapter 6 deals with asynchronous code, such as promises, timers, and events, and various approaches to testing such code.
在上一章中,您使用 Jest 编写了第一个单元测试,我们更多地关注了测试本身的可维护性。场景非常简单,更重要的是,它是完全独立的。密码验证器不依赖外部模块,我们可以专注于其功能,而不必担心其他可能干扰它的事情。
In the previous chapter, you wrote your first unit test using Jest, and we looked more at the maintainability of the test itself. The scenario was pretty simple, and more importantly, it was completely self-contained. The Password Verifier had no reliance on outside modules, and we could focus on its functionality without worrying about other things that might interfere with it.
在该章中,我们在示例中使用了前两种类型的退出点:返回值退出点和基于状态的退出点。在本章中,我们将讨论最后一种类型——调用第三方。本章还将提出一个新的要求——让你的代码依赖于时间。我们将研究两种不同的处理方法——重构代码和在不重构的情况下对其进行猴子修补。
In that chapter, we used the first two types of exit points for our examples: return value exit points and state-based exit points. In this chapter, we’ll talk about the final type—calling a third party. This chapter will also present a new requirement—having your code rely on time. We’ll look at two different approaches to handling it—refactoring our code and monkey-patching it without refactoring.
对外部模块或函数的依赖可能并且将会使编写测试和使测试可重复变得更加困难,并且还可能导致测试不稳定。我们将代码中所依赖的外部事物称为依赖项。我将在本章后面更彻底地定义它们。这些依赖项可能包括时间、异步执行、使用文件系统或使用网络等内容,或者它们可能只是涉及使用非常难以配置或执行起来可能很耗时的内容。
The reliance on outside modules or functions can and will make it harder to write a test and to make the test repeatable, and it can also cause tests to be flaky. We call the external things that we rely on in our code dependencies. I’ll define them more thoroughly later in the chapter. These dependencies could include things like time, async execution, using the filesystem, or using the network, or they could simply involve using something that is very difficult to configure or that may be time consuming to execute.
根据我的经验,我们的工作单元可以使用两种主要类型的依赖关系:
In my experience, there are two main types of dependencies that our unit of work can use:
传出依赖项- 代表我们工作单元的退出点的依赖项,例如调用记录器、将某些内容保存到数据库、发送电子邮件、通知 API 或 Webhook 发生了某些事情等。请注意,这些都是动词: “呼叫”、“发送”和“通知”。它们以一种“即发即忘”的方式从工作单元向外流动。每个都代表一个退出点,或者工作单元中特定逻辑流的结束。
Outgoing dependencies—Dependencies that represent an exit point of our unit of work, such as calling a logger, saving something to a database, sending an email, notifying an API or a webhook that something has happened, etc. Notice these are all verbs: “calling,” “sending,” and “notifying.” They are flowing outward from the unit of work in a sort of fire-and-forget scenario. Each represents an exit point, or the end of a specific logical flow in a unit of work.
传入依赖项——不是退出点的依赖项。这些并不代表对工作单元的最终行为的要求。它们只是向工作单元提供特定于测试的专用数据或行为,例如数据库查询的结果、文件系统上文件的内容、网络响应等。请注意,这些都是被动数据作为先前操作的结果流入工作单元。
Incoming dependencies—Dependencies that are not exit points. These do not represent a requirement on the eventual behavior of the unit of work. They are merely there to provide test-specific specialized data or behavior to the unit of work, such as a database query’s result, the contents of a file on the filesystem, a network response, etc. Notice that these are all passive pieces of data that flow inward to the unit of work as the result of a previous operation.
Figure 3.1 shows these side by side.
图 3.1 在左侧,退出点通过调用依赖项来实现。右侧,依赖项提供间接输入或行为,而不是退出点。
Figure 3.1 On the left, an exit point is implemented as invoking a dependency. On the right, the dependency provides indirect input or behavior and is not an exit point.
某些依赖项既可以是传入的,也可以是传出的——在某些测试中,它们将代表退出点,而在其他测试中,它们将用于模拟进入应用程序的数据。这些应该不是很常见,但它们确实存在,例如为传出消息返回成功/失败响应的外部 API。
Some dependencies can be both incoming and outgoing—in some tests they will represent exit points, and in other tests they will be used to simulate data coming into the application. These shouldn’t be very common, but they do exist, such as an external API that returns a success/fail response for an outgoing message.
考虑到这些类型的依赖关系,让我们看看《xUnit 测试模式》一书如何为测试中与其他事物类似的事物定义各种模式。表 3.1 列出了我对本书网站http://mng.bz/n1WK中的一些模式的看法。
With these types of dependencies in mind, let’s look at how the book xUnit Test Patterns defines the various patterns for things that look like other things in tests. Table 3.1 lists my thoughts about some patterns from the book’s website at http:// mng.bz/n1WK.
Table 3.1 Clarifying terminology around stubs and mocks
Here’s another way to think about this for the rest of this book:
桩打破了传入的依赖关系(间接输入)。桩是假模块、对象或函数,它们向被测代码提供假行为或数据。我们并不反对他们。我们可以在一次测试中拥有许多桩。
Stubs break incoming dependencies (indirect inputs). Stubs are fake modules, objects, or functions that provide fake behavior or data into the code under test. We do not assert against them. We can have many stubs in a single test.
模拟打破了传出依赖关系(间接输出或退出点)。模拟是我们断言在测试中调用的假模块、对象或函数。模拟代表单元测试中的退出点。因此,建议每个测试不要超过一个模拟。
Mocks break outgoing dependencies (indirect outputs or exit points). Mocks are fake modules, objects, or functions that we assert were called in our tests. A mock represents an exit point in a unit test. Because of this, it is recommended that you have no more than a single mock per test.
不幸的是,在许多商店中,您会听到“mock”这个词,它是桩和模拟的统称。像“我们将模拟这个”或“我们有一个模拟数据库”这样的短语确实会造成混乱。桩和模拟之间存在巨大差异(一个实际上应该在测试中只使用一次),我们应该使用正确的术语来确保清楚对方所指的内容。
Unfortunately, in many shops you’ll hear the word “mock” thrown around as a catch-all term for both stubs and mocks. Phrases like “we’ll mock this out” or “we have a mock database” can really create confusion. There is a huge difference between stubs and mocks (one should really only be used once in a test), and we should use the right terms to ensure it’s clear what the other person is referring to.
如有疑问,请使用术语“测试替身”或“假的”。通常,单个假依赖项可以在一个测试中用作桩,并且可以在另一测试中用作模拟。稍后我们会看到一个这样的例子。
When in doubt, use the term “test double” or “fake.” Often, a single fake dependency can be used as a stub in one test, and it can be used as a mock in another test. We’ll see an example of this later on.
这看起来似乎同时包含了很多信息。我将在本章中深入探讨这些定义。让我们先从桩开始吧。
This might seem like a whole lot of information at once. I’ll dive deep into these definitions throughout this chapter. Let’s take a small bite and start with stubs.
What if we’re faced with the task of testing a piece of code like the following?
Listing 3.1 verifyPassword using time
const moment = require('moment');
const 周日 = 0,周六 = 6;
const verifyPassword = (输入, 规则) => {
const dayOfWeek = moment().day() ;
if ([周六、周日].includes(dayOfWeek)) {
抛出错误(“这是周末!”);
}
//更多代码在这里...
//返回发现的错误列表..
返回 [];
};const moment = require('moment');
const SUNDAY = 0, SATURDAY = 6;
const verifyPassword = (input, rules) => {
const dayOfWeek = moment().day();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
}
//more code goes here...
//return list of errors found..
return [];
};
我们的密码验证器有一个新的依赖项:它无法在周末工作。去搞清楚。具体来说,该模块直接依赖于 moment.js,这是 JavaScript 的一个非常常见的日期/时间包装器。直接在 JavaScript 中处理日期并不是一种愉快的体验,因此我们可以假设许多商店都有类似的东西。
Our password verifier has a new dependency: it can’t work on weekends. Go figure. Specifically, the module has a direct dependency on moment.js, which is a very common date/time wrapper for JavaScript. Working with dates directly in JavaScript is not a pleasant experience, so we can assume many shops out there have something like this.
直接使用与时间相关的库对我们的单元测试有何影响?这里不幸的问题是,这种直接依赖性迫使我们的测试考虑正确的日期和时间,因为没有直接的方法来影响被测应用程序内的日期和时间。以下清单显示了一个不幸的测试,该测试仅在周末运行。
How does this direct use of a time-related library affect our unit tests? The unfortunate issue here is that this direct dependency forces our tests, given no direct way to affect date and time inside our application under test, to take into account the correct date and time. The following listing shows an unfortunate test that only runs on weekends.
Listing 3.2 Initial unit tests for verifyPassword
const moment = require('moment');
const {verifyPassword} = require("./password-verifier-time00");
const 周日 = 0,周六 = 6,周一 = 2;
描述('验证者', () => {
const TODAY = moment().day();
//测试总是被执行,但可能不会做任何事情
test('周末,抛出异常', () => {
if ([周六、周日].includes(今天)) { ❶
期望(()=>验证密码('任何东西',[]))
.toThrow("周末到了!");
}
});
//测试甚至不在工作日执行
if ([周六、周日].includes(今天)) { ❷
test('周末,抛出错误', () => {
期望(()=> verifyPassword('任何东西',[]))
.toThrow("周末到了!");
});
}
});const moment = require('moment');
const {verifyPassword} = require("./password-verifier-time00");
const SUNDAY = 0, SATURDAY = 6, MONDAY = 2;
describe('verifier', () => {
const TODAY = moment().day();
//test is always executed, but might not do anything
test('on weekends, throws exceptions', () => {
if ([SATURDAY, SUNDAY].includes(TODAY)) { ❶
expect(()=> verifyPassword('anything',[]))
.toThrow("It's the weekend!");
}
});
//test is not even executed on week days
if ([SATURDAY, SUNDAY].includes(TODAY)) { ❷
test('on a weekend, throws an error', () => {
expect(()=> verifyPassword('anything', []))
.toThrow("It's the weekend!");
});
}
});
❶ Checking the date inside the test
❷ Checking the date outside the test
前面的清单包括同一测试的两个变体。一个在测试内检查当前日期,另一个在测试外检查,这意味着除非是周末,否则测试甚至不会执行。这不好。
The preceding listing includes two variations on the same test. One checks for the current date inside the test, and the other has the check outside the test, which means the test never even executes unless it’s the weekend. This is bad.
让我们回顾一下第一章中提到的良好测试品质之一:一致性:每次我运行测试时,它都与我之前运行的测试完全相同。所使用的值不会改变。断言不会改变。如果没有代码发生更改(在测试或生产代码中),则测试应提供与之前运行完全相同的结果。
Let’s revisit one of the good test qualities mentioned in chapter 1, consistency: Every time I run a test, it is the same exact test that I ran before. The values being used do not change. The asserts do not change. If no code has changed (in test or production code), then the test should provide the exact same result as previous runs.
第二个测试有时甚至无法运行。这是使用伪造品来打破依赖性的充分理由。此外,我们无法模拟周末或工作日,这给了我们足够的动力来重新设计被测试的代码,因此它更容易注入依赖项。
The second test sometimes doesn’t even run. That’s a good enough reason to use a fake to break the dependency right there. Furthermore, we can’t simulate a weekend or a weekday, which gives us more than enough incentive to redesign the code under test so it’s a bit more injectable for dependencies.
但等等,还有更多。使用时间的测试通常是不稳定的。他们只是有时会失败,除了时间的变化之外什么也没有。该测试是这种行为的主要候选者,因为当我们在本地运行它时,我们只会获得有关其两个状态之一的反馈。如果您想知道周末的表现如何,只需等待几天即可。啊。
But wait, there’s more. Tests that use time can often be flaky. They only fail sometimes, without anything but the time changing. This test is a prime candidate for this behavior, because we’ll only get feedback on one of its two states when we run it locally. If you want to know how it behaves on a weekend, just wait a couple of days. Ugh.
由于影响测试中不受我们控制的变量的边缘情况,测试可能会变得不稳定。常见的示例是端到端测试期间的网络问题、数据库连接问题或各种服务器问题。当发生这种情况时,很容易通过说“再次运行它”或“没关系”来消除测试失败。这只是[在此处插入可变性问题]。”
Tests might become flaky due to edge cases that affect variables that are not under our control in the test. Common examples are network issues during end-to-end testing, database connectivity issues, or various server issues. When this happens, it’s easy to dismiss the test failure by saying “just run it again” or “It’s OK. It’s just [insert variability issue here].”
在接下来的几节中,我们将讨论将桩注入工作单元的几种常见形式。首先,我们将讨论基本参数化作为第一步,然后我们将跳转到以下方法:
In the next few sections, we’ll discuss several common forms of injecting stubs into our units of work. First, we’ll discuss basic parameterization as a first step, then we’ll jump into the following approaches:
We’ll tackle each of these by starting with the simple case of controlling time in our tests.
根据我们到目前为止所讨论的内容,我至少可以想到两个控制时间的好理由:
I can think of at least two good reasons to control time based on what we’ve covered so far:
这是我能想到的最简单的重构,它使事情更具可重复性。让我们currentDay向函数添加一个参数来指定当前日期。这将消除在我们的函数中使用 moment.js 模块的需要,并将该责任交给函数的调用者。这样,在我们的测试中,我们可以以硬编码的方式确定时间,并使测试和函数可重复且一致。以下清单显示了此类重构的示例。
Here’s the simplest refactoring I can think of that makes things a bit more repeatable. Let’s add a currentDay parameter to our function to specify the current date. This will remove the need to use the moment.js module in our function, and it will put that responsibility on the caller of the function. That way, in our tests, we can determine the time in a hardcoded manner and make the test and the function repeatable and consistent. The following listing shows an example of such a refactoring.
清单 3.3verifyPassword带currentDay参数
Listing 3.3 verifyPassword with a currentDay parameter
const verifyPassword2 = (输入, 规则, currentDay ) => {
if ([星期六, 星期日].includes( currentDay )) {
抛出错误(“这是周末!”);
}
//更多代码在这里...
//返回发现的错误列表..
返回 [];
};
const 周日 = 0,周六 = 6 ,周一 = 1 ;
描述('verifier2 - 虚拟对象', () => {
test('周末,抛出异常', () => {
Expect(() => verifyPassword2 ('任何东西',[], SUNDAY ))
.toThrow("周末到了!");
});
});const verifyPassword2 = (input, rules, currentDay) => {
if ([SATURDAY, SUNDAY].includes(currentDay)) {
throw Error("It's the weekend!");
}
//more code goes here...
//return list of errors found..
return [];
};
const SUNDAY = 0, SATURDAY = 6, MONDAY = 1;
describe('verifier2 - dummy object', () => {
test('on weekends, throws exceptions', () => {
expect(() => verifyPassword2('anything',[],SUNDAY ))
.toThrow("It's the weekend!");
});
});
通过添加currentDay参数,我们实质上将时间控制权交给了函数的调用者(我们的测试)。我们注入的内容正式称为“虚拟”——它只是一段没有行为的数据——但从现在开始我们可以将其称为“桩”。
By adding the currentDay parameter, we’re essentially giving control over time to the caller of the function (our test). What we’re injecting is formally called a “dummy”—it’s just a piece of data with no behavior—but we can call it a “stub” from now on.
这种方法是依赖倒置的一种形式。术语“控制反转”似乎首次出现在 Johnson 和 Foote于 1988 年在《面向对象编程杂志》上发表的论文“设计可重用类”中。术语“依赖反转”也是 SOLID 描述的模式之一。 Robert C. Martin 2000 年在他的“设计原则和设计模式”论文中。我将在第 8 章中详细讨论更高层次的设计注意事项。
This is approach is a form of Dependency Inversion. It seems the term “Inversion of Control” first came up in Johnson and Foote’s paper “Designing Reusable Classes,” published by the Journal of Object-Oriented Programming in 1988. The term “Dependency Inversion” is also one of the SOLID patterns described by Robert C. Martin in 2000, in his “Design Principles and Design Patterns” paper. I’ll talk more about higher-level design considerations in chapter 8.
添加这个参数是一个简单的重构,但是非常有效。除了测试的一致性之外,它还提供了一些不错的好处:
Adding this parameter is a simple refactoring, but it’s quite effective. It provides a couple of nice benefits other than consistency in the test:
The code under test is not responsible for managing time imports, so it has one less reason to change if we ever use a different time library.
我们正在将时间“依赖注入”到我们的工作单元中。我们更改了入口点的设计,以使用日期值作为参数。按照函数式编程标准,该函数现在是“纯粹的”,因为它没有副作用。纯函数具有所有依赖项的内置注入,这是您会发现函数式编程设计通常更容易测试的原因之一。
We’re doing “dependency injection” of time into our unit of work. We’ve changed the design of the entry point to use a day value as a parameter. The function is now “pure” by functional programming standards in that it has no side effects. Pure functions have built-in injections of all of their dependencies, which is one of the reasons you’ll find functional programming designs are typically much easier to test.
Figure 3.2 Injecting a stub for a time dependency
currentDay如果参数只是一天的整数值,那么将参数称为桩可能会感觉很奇怪,但根据xUnit 测试模式的定义,我们可以说这是一个“虚拟”值,就我而言,它属于“桩”类别。它不必很复杂才能成为桩。它只需要在我们的控制之下。它是一个桩,因为我们使用它来模拟传递到被测单元的某些输入或行为。图 3.2 直观地展示了这一点。
It might feel weird to call the currentDay parameter a stub if it’s just a day integer value, but based on the definitions from xUnit Test Patterns, we can say that this is a “dummy” value, and as far as I’m concerned, it falls into the “stub” category. It does not have to be complex in order to be a stub. It just has to be under our control. It’s a stub because we are using it to simulate some input or behavior being passed into the unit under test. Figure 3.2 shows this visually.
表 3.2 回顾了我们已经讨论过的一些重要术语,并将在本章的其余部分中使用。
Table 3.2 recaps some important terms we’ve discussed and are about to use throughout the rest of the chapter.
Table 3.2 Terminology used in this chapter
生产代码中的接缝对于单元测试的可维护性和可读性起着重要作用。更改行为或自定义数据并将其注入到被测代码中越容易,随着生产代码更改,编写、读取和维护测试就越容易。我将在第 8 章中详细讨论与设计代码相关的一些模式和反模式。
Seams in production code play an important role in the maintainability and readability of unit tests. The easier it is to change and inject behavior or custom data into the code under test, the easier it will be to write, read, and later on maintain the test as the production code changes. I’ll talk more about some patterns and antipatterns related to designing code in chapter 8.
此时,我们可能对我们的设计选择不满意。添加参数确实解决了函数级别的依赖性问题,但现在每个调用者都需要知道如何以某种方式处理日期。感觉有点太啰嗦了。
At this point, we might not be happy with our design choice. Adding a parameter did solve the dependency issue at the function level, but now every caller will need to know how to handle dates in some way. It feels a bit too chatty.
JavaScript 支持两种主要的编程风格——函数式和面向对象——所以我将在有意义的时候展示这两种风格的方法,并且您可以选择最适合您情况的方法。
JavaScript enables two major styles of programming—functional and object-oriented—so I’ll show approaches in both styles when it makes sense, and you can pick and choose what works best in your situation.
设计东西没有单一的方法。函数式编程的支持者会主张函数式风格的简单性、清晰性和可证明性,但它确实有一个学习曲线。仅出于这个原因,学习这两种方法是明智的,这样您就可以应用最适合您正在合作的团队的方法。有些团队会更倾向于面向对象的设计,因为他们对此感到更舒服。其他人会倾向于功能性设计。我认为这些模式基本上是相同的。我们只是将它们翻译成不同的风格。
There isn’t a single way to design something. Functional programming proponents will argue for the simplicity, clarity, and provability of the functional style, but it does come with a learning curve. For that reason alone, it is wise to learn both approaches so that you can apply whichever works best for the team you’re working with. Some teams will lean more toward object-oriented designs because they feel more comfortable with that. Others will lean towards functional designs. I’d argue that the patterns remain largely the same; we just translate them to different styles.
下面的清单显示了针对同一问题的不同重构:我们期望函数作为参数,而不是数据对象。该函数返回日期对象。
The following listing shows a different refactoring for the same problem: instead of a data object, we’re expecting a function as the parameter. That function returns the date object.
Listing 3.4 Dependency injection with a function
const verifyPassword3 = (输入, 规则, getDayFn ) => {
const dayOfWeek = getDayFn() ;
if ([周六、周日].includes(dayOfWeek)) {
抛出错误(“这是周末!”);
}
//更多代码在这里...
//返回发现的错误列表..
返回 [];
};const verifyPassword3 = (input, rules, getDayFn) => {
const dayOfWeek = getDayFn();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
}
//more code goes here...
//return list of errors found..
return [];
};
The associated test is shown in the following listing.
Listing 3.5 Testing with function injection
描述('verifier3 - 虚拟函数', () => {
test('周末,抛出异常', () => {
const 总是星期日 = () => 星期日;
期望(()=> verifyPassword3('任何东西',[],alwaysSunday))
.toThrow("周末到了!");
});describe('verifier3 - dummy function', () => {
test('on weekends, throws exceptions', () => {
const alwaysSunday = () => SUNDAY;
expect(()=> verifyPassword3('anything',[], alwaysSunday))
.toThrow("It's the weekend!");
});
与之前的测试没有什么区别,但是使用函数作为参数是进行注入的有效方法。在其他场景中,这也是启用特殊行为的好方法,例如在测试的代码中模拟特殊情况或异常。
There’s very little difference from the previous test, but using a function as a parameter is a valid way to do injection. In other scenarios, it’s also a great way to enable special behavior, such as simulating special cases or exceptions in your code under test.
工厂函数或方法(“高阶函数”的子类别)是返回其他函数的函数,并预先配置了一些上下文。在我们的例子中,上下文可以是规则列表和当天的函数。然后,我们返回一个新函数,只需输入字符串即可触发该函数,它将使用getDay()在其创建时配置的规则和函数。
Factory functions or methods (a subcategory of “higher-order functions”) are functions that return other functions, preconfigured with some context. In our case, the context can be the list of rules and the current day function. We then get back a new function that we can trigger with only a string input, and it will use the rules and getDay() function configured in its creation.
以下清单中的代码本质上将工厂函数转换为测试的安排部分,并将返回的函数调用为行动部分。相当可爱。
The code in the following listing essentially turns the factory function into the arrange part of the test, and calls the returned function into the act part. Quite lovely.
Listing 3.6 Using a higher-order factory function
常量星期日 = 0, . 。。周五=5,周六=6;
const makeVerifier = (rules, dayOfWeekFn) => {
返回函数 (输入) {
if ([周六、周日].includes(dayOfWeekFn())) {
抛出新的错误(“这是周末!”);
}
//更多代码在这里..
};
};
描述('验证者', () => {
test('工厂方法:周末,抛出异常', () => {
const 总是星期日 = () => 星期日;
const verifyPassword = makeVerifier([], alwaysSunday);
期望(()=>验证密码('任何东西'))
.toThrow("周末到了!");
});const SUNDAY = 0, . . . FRIDAY=5, SATURDAY = 6;
const makeVerifier = (rules, dayOfWeekFn) => {
return function (input) {
if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
throw new Error("It's the weekend!");
}
//more code goes here..
};
};
describe('verifier', () => {
test('factory method: on weekends, throws exceptions', () => {
const alwaysSunday = () => SUNDAY;
const verifyPassword = makeVerifier([], alwaysSunday);
expect(() => verifyPassword('anything'))
.toThrow("It's the weekend!");
});
JavaScript 还允许模块的概念,我们import或require. 当我们的测试代码中直接导入依赖项时,例如清单 3.1 中的代码(此处再次显示),我们如何处理依赖项注入的想法?
JavaScript also allows for the idea of modules, which we import or require. How can we handle the idea of dependency injection when faced with a direct import of a dependency in our code under test, such as in the code from listing 3.1, shown again here?
const moment = require('moment');
周日 = 0; 常量星期六 = 6;
const verifyPassword = (输入, 规则) => {
const dayOfWeek = moment() .day();
if ([周六、周日].includes(dayOfWeek)) {
抛出错误(“这是周末!”);
}
// 这里还有更多代码...
// 返回发现的错误列表..
返回 [];
};const moment = require('moment');
const SUNDAY = 0; const SATURDAY = 6;
const verifyPassword = (input, rules) => {
const dayOfWeek = moment().day();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
}
// more code goes here...
// return list of errors found..
return [];
};
我们怎样才能克服这种直接依赖呢?答案是,我们不能。我们必须以不同的方式编写代码,以便稍后替换该依赖项。我们必须创建一个接缝,通过它我们可以替换我们的依赖项。这是一个这样的例子。
How can we overcome this direct dependency that’s happening? The answer is, we can’t. We’ll have to write the code differently to allow for the replacement of that dependency later on. We’ll have to create a seam through which we can replace our dependencies. Here’s one such example.
Listing 3.7 Abstracting the required dependencies
const originDependency = { ❶
moment: require('moment'), ❶
}; ❶
让依赖项 = { ...originalDependency }; ❷
const Inject = (fakes) => { ❸
Object.assign(dependency, fakes);
返回函数重置(){ ❹
依赖项={...originalDependencies};
}
};
周日 = 0; 常量星期六 = 6;
const verifyPassword = (输入, 规则) => {
const dayOfWeek =依赖项。时刻().day();
if ([周六、周日].includes(dayOfWeek)) {
抛出错误(“这是周末!”);
}
// 这里还有更多代码...
// 返回发现的错误列表..
返回 [];
};
模块. 导出 = {
周六,
验证密码,
注入
};const originalDependencies = { ❶
moment: require(‘moment’), ❶
}; ❶
let dependencies = { ...originalDependencies }; ❷
const inject = (fakes) => { ❸
Object.assign(dependencies, fakes);
return function reset() { ❹
dependencies = { ...originalDependencies };
}
};
const SUNDAY = 0; const SATURDAY = 6;
const verifyPassword = (input, rules) => {
const dayOfWeek = dependencies.moment().day();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
}
// more code goes here...
// return list of errors found..
return [];
};
module.exports = {
SATURDAY,
verifyPassword,
inject
};
❶ Wrapping moment.js with an intermediary object
❷ The object containing the current dependency, either real or fake
❸ A function that replaces the real dependency with a fake one
❹ A function that resets the dependency back to the real one
What’s going on here? Three new things have been introduced:
首先,我们用一个对象替换了对 moment.js 的直接依赖:originalDependencies。它包含该模块导入作为其实现的一部分。
First, we have replaced our direct dependency on moment.js with an object: originalDependencies. It contains that module import as part of its implementation.
接下来,我们在组合中添加了另一个对象:dependencies。默认情况下,该对象承担该originalDependencies对象包含的所有实际依赖项。
Next, we have added yet another object into the mix: dependencies. This object, by default, takes on all of the real dependencies that the originalDependencies object contains.
最后,该inject函数(我们也将其作为我们自己的模块的一部分公开)允许导入我们的模块(生产代码和测试)的任何人用自定义依赖项(假项)覆盖我们的真实依赖项。
Finally, the inject function, which we’re also exposing as part of our own module, allows whoever is importing our module (both production code and tests) to override our real dependencies with custom dependencies (fakes).
当您调用 时inject,它会返回一个reset函数,该函数将原始依赖项重新应用到当前dependencies变量上,从而重置当前使用的所有伪造品。
When you invoke inject, it returns a reset function that reapplies the original dependencies onto the current dependencies variable, thus resetting any fakes currently being used.
Here’s how you can use the inject and reset functions in a test.
Listing 3.8 Injecting a fake module with inject()
const {注入, verifyPassword, 星期六 } = require('./password-verifier-time00-modular');
constjectDate=(newDay)=>{ ❶constreset
=inject({ ❷
时刻:function(){
//我们在这里伪造了 moment.js 模块的 API。
返回 {
天: () => newDay
}
}
});
返回重置;
};
描述('验证密码',()=> {
描述('什么时候是周末', () => {
it('抛出错误', () => {
const重置=注入日期(星期六); ❸
Expect(() => verifyPassword('任何输入'))
.toThrow("周末到了!");
重置(); ❹
});
});
});const { inject, verifyPassword, SATURDAY } = require('./password-verifier-time00-modular');
const injectDate = (newDay) => { ❶
const reset = inject({ ❷
moment: function () {
//we're faking the moment.js module's API here.
return {
day: () => newDay
}
}
});
return reset;
};
describe('verifyPassword', () => {
describe('when its the weekend', () => {
it('throws an error', () => {
const reset = injectDate(SATURDAY); ❸
expect(() => verifyPassword('any input'))
.toThrow("It's the weekend!");
reset(); ❹
});
});
});
❷ Injecting a fake API instead of moment.js
Let’s break down what’s going on here:
该injectDate函数只是一个辅助函数,旨在减少我们测试中的样板代码。它总是构建 moment.js API 的虚假结构,并将其getDay函数设置为返回newDay参数。
The injectDate function is just a helper function meant to reduce the boilerplate code in our test. It always builds the fake structure of the moment.js API, and it sets its getDay function to return the newDay parameter.
该injectDate函数inject使用新的 fake moment.js API 进行调用。这将我们工作单元中的假依赖关系应用到我们作为参数发送的依赖关系。
The injectDate function calls inject with the new fake moment.js API. This applies the fake dependency in our unit of work to the one we have sent in as a parameter.
At the end of the test, we call the reset function, which resets the unit of work’s module dependencies to the original ones.
一旦你这样做了几次,它就开始有意义了。但它也有一些警告。从专业角度来看,它确实解决了我们测试中的依赖问题,并且相对易于使用。至于缺点,据我所知,有一个巨大的缺点。使用此方法来伪造我们的模块化依赖项会迫使我们的测试与我们所伪造的依赖项的 API 签名紧密相关。如果这些是第三方依赖项,例如 moment.js、记录器或我们无法完全控制的其他任何东西,那么当需要升级或用某些东西替换依赖项时(一如既往),我们的测试将变得非常脆弱具有不同的 API。如果只是一两个测试,这不会造成太大伤害,但我们通常会有数百或数千个测试,这些测试必须伪造几个常见的依赖项,这有时意味着在用破坏性的记录器替换记录器时更改和修复数百个文件例如,API 更改。
Once you’ve done this a couple of times, it starts making sense. But it has some caveats as well. On the pro side, it definitely takes care of the dependency issue in our tests, and it’s relatively easy to use. As for the cons, there is one huge downside as far as I can see. Using this method to fake our modular dependencies forces our tests to be closely tied to the API signature of the dependencies we are faking. If these are third-party dependencies, such as moment.js, loggers, or anything else that we do not fully control, our tests will become very brittle when the time comes (as it always does) to upgrade or replace the dependencies with something that has a different API. This doesn’t hurt much if it’s just a test or two, but we’ll usually have hundreds or thousands of tests that have to fake several common dependencies, and that sometimes means changing and fixing hundreds of files when replacing a logger with a breaking API change, for example.
I have two possible ways to prevent such a situation:
切勿导入您无法直接在代码中控制的第三方依赖项。始终使用您可以控制的临时抽象。端口和适配器架构是这种想法的一个很好的例子(该架构的其他名称是六角形架构和洋葱架构)。有了这样的架构,伪造这些内部 API 的风险应该会更小,因为我们可以控制它们的变化率,从而使我们的测试不那么脆弱。(即使外部世界发生变化,我们也可以在内部重构它们,而无需我们的测试关心。)
Never import a third-party dependency that you don’t control directly in your code. Always use an interim abstraction that you do control. The Ports and Adapters architecture is a good example of such an idea (other names for this architecture are Hexagonal architecture and Onion architecture). With such an architecture, faking these internal APIs should present less risk, because we can control their rate of change, thus making our tests less brittle. (We can refactor them internally without our tests caring, even if the outside world changes.)
避免使用模块注入,而是使用本书中提到的其他方法之一进行依赖项注入:函数参数、柯里化以及下一节中提到的构造函数和接口。在这些之间,您应该有很多选择,而不是直接导入东西。
Avoid using module injection, and instead use one of the other ways mentioned in this book for dependency injection: function parameters, currying, and, as mentioned in the next section, constructors and interfaces. Between these, you should have plenty of choices instead of importing things directly.
构造函数是一种稍微更面向对象的 JavaScript 方式,可以实现与工厂函数相同的结果,但它们返回类似于带有我们可以触发的方法的对象的东西。然后我们使用关键字new调用该函数并返回该特殊对象。
Constructor functions are a slightly more object-oriented JavaScript-ish way of achieving the same result as a factory function, but they return something akin to an object with methods we can trigger. We then use the keyword new to call this function and get back that special object.
Here’s what the same code and tests look like with this design choice.
Listing 3.9 Using a constructor function
const Verifier = 函数(rules, dayOfWeekFn)
{
this.verify = 函数(输入) {
if ([周六、周日].includes(dayOfWeekFn())) {
抛出新的错误(“这是周末!”);
}
//更多代码在这里..
};
};
const {Verifier} = require("./password-verifier-time01");
test('构造函数:周末,抛出异常', () => {
const 总是星期日 = () => 星期日;
const verifier = new Verifier([], alwaysSunday);
Expect(() => verifier.verify('anything') )
.toThrow("周末到了!");
});const Verifier = function(rules, dayOfWeekFn)
{
this.verify = function (input) {
if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
throw new Error("It's the weekend!");
}
//more code goes here..
};
};
const {Verifier} = require("./password-verifier-time01");
test('constructor function: on weekends, throws exception', () => {
const alwaysSunday = () => SUNDAY;
const verifier = new Verifier([], alwaysSunday);
expect(() => verifier.verify('anything'))
.toThrow("It's the weekend!");
});
你可能会看到这个并问:“为什么要朝着物体移动?” 答案实际上取决于您当前项目的背景、其堆栈、您的团队对函数式编程和面向对象背景的了解,以及许多其他非技术因素。最好将此工具放在您的工具箱中,这样您就可以在需要时使用它。当您阅读接下来的几节时,请牢记这一点。
You might look at this and ask, “Why move toward objects?” The answer really depends on the context of your current project, its stack, your team’s knowledge of functional programming and object-oriented background, and many other non-technical factors. It’s good to have this tool in your toolbox so you can use it when it makes sense to you. Keep this in the back of your mind as you read the next few sections.
如果您倾向于更面向对象的风格,或者您正在使用 C# 或 Java 等面向对象语言,那么这里有一些在面向对象世界中广泛使用的常见模式依赖注入。
If a more object-oriented style is what you’re leaning toward, or if you’re working in an object-oriented language such as C# or Java, here are a few common patterns that are widely used in the object-oriented world for dependency injection.
构造函数注入是我描述一种设计的方式,在该设计中我们可以通过类的构造函数注入依赖项。在 JavaScript 世界中,Angular 是最著名的 Web 前端框架,它使用这种设计来注入“服务”,这只是 Angular 语言中“依赖项”的代号。在许多其他情况下,这是一种可行的设计。
Constructor injection is how I would describe a design in which we can inject dependencies through the constructor of a class. In the JavaScript world, Angular is the best-known web frontend framework that uses this design for injecting “services,” which is just a code word for “dependencies” in Angular-speak. This is a viable design in many other situations.
拥有一个有状态的类并不是没有好处的。它可以消除客户端的重复,只需要配置我们的类一次,然后可以多次重用配置的类。
Having a stateful class is not without benefits. It can remove repetition from clients that only need to configure our class once and can then reuse the configured class multiple times.
如果我们选择创建有状态版本的密码验证器,并且希望通过构造函数注入来注入日期函数,则它可能类似于以下设计。
If we had chosen to create a stateful version of Password Verifier, and we wanted to inject the date function through constructor injection, it might look like the following design.
Listing 3.10 Constructor injection design
类PasswordVerifier {
构造函数(rules, dayOfWeekFn) {
this.rules = 规则;
this.dayOfWeek = dayOfWeekFn;
}
验证(输入){
if ([星期六, 星期日].includes(this.dayOfWeek())) {
抛出新的错误(“这是周末!”);
}
常量错误=[];
//更多代码在这里..
返回错误;
};
}
test('类构造函数:周末,抛出异常', () => {
const 总是星期日 = () => 星期日;
constverifier= newPasswordVerifier([],alwaysSunday);
Expect(() => verifier.verify('anything') )
.toThrow("周末到了!");
});class PasswordVerifier {
constructor(rules, dayOfWeekFn) {
this.rules = rules;
this.dayOfWeek = dayOfWeekFn;
}
verify(input) {
if ([SATURDAY, SUNDAY].includes(this.dayOfWeek())) {
throw new Error("It's the weekend!");
}
const errors = [];
//more code goes here..
return errors;
};
}
test('class constructor: on weekends, throws exception', () => {
const alwaysSunday = () => SUNDAY;
const verifier = new PasswordVerifier([], alwaysSunday);
expect(() => verifier.verify('anything'))
.toThrow("It's the weekend!");
});
这看起来和感觉很像 3.6 节中的构造函数设计。这是一种更加面向类的设计,许多来自面向对象背景的人会觉得更舒服。它也更详细。你会发现我们制作的东西越面向对象,我们就会变得越来越冗长。它是面向对象游戏的一部分。这就是人们越来越多地选择功能性风格的部分原因——它们更加简洁。
This looks and feels a lot like the constructor function design in section 3.6. This is a more class-oriented design that many people will feel more comfortable with, coming from an object-oriented background. It also is more verbose. You’ll see that we get more and more verbose the more object-oriented we make things. It’s part of the object-oriented game. This is partly why people are choosing functional styles more and more—they are much more concise.
让我们谈谈测试的可维护性。如果我用这个类编写第二个测试,我会通过构造函数将类的创建提取到一个漂亮的小工厂函数中,该函数返回被测试类的实例,以便 if(即“when”)构造函数签名更改并立即破坏许多测试,我只需要修复一个地方即可使所有测试再次工作,如您在下面的清单中看到的。
Let’s talk a bit about the maintainability of the tests. If I wrote a second test with this class, I’d extract the creation of the class via the constructor to a nice little factory function that returns an instance of the class under test, so that if (i.e., “when”) the constructor signature changes and breaks many tests at once, I only have to fix a single place to get all the tests working again, as you can see in the following listing.
Listing 3.11 Adding a helper factory function to our tests
describe('用构造函数重构', () => {
const makeVerifier = (rules, dayFn) => {
return new PasswordVerifier(rules, dayFn);
};
test('类构造函数:周末,抛出异常', () => {
const 总是星期日 = () => 星期日;
const verifier = makeVerifier([],alwaysSunday);
Expect(() => verifier.verify('任何东西'))
.toThrow("周末到了!");
});
test('类构造函数:平日里,没有规则,通过', () => {
const 总是星期一 = () => 星期一;
const verifier = makeVerifier([],alwaysMonday);
const 结果 = verifier.verify('任何东西');
期望(结果.长度).toBe(0);
});
});describe('refactored with constructor', () => {
const makeVerifier = (rules, dayFn) => {
return new PasswordVerifier(rules, dayFn);
};
test('class constructor: on weekends, throws exceptions', () => {
const alwaysSunday = () => SUNDAY;
const verifier = makeVerifier([],alwaysSunday);
expect(() => verifier.verify('anything'))
.toThrow("It's the weekend!");
});
test('class constructor: on weekdays, with no rules, passes', () => {
const alwaysMonday = () => MONDAY;
const verifier = makeVerifier([],alwaysMonday);
const result = verifier.verify('anything');
expect(result.length).toBe(0);
});
});
请注意,这与 3.4.2 节中的工厂功能设计不同。这个工厂函数驻留在我们的测试中;另一个在我们的生产代码中。这是为了测试可维护性,它可以与面向对象和功能性生产代码一起使用,因为它隐藏了函数或对象的创建或配置方式。它是我们测试中的抽象层,因此我们可以将对函数或对象如何创建或配置的依赖关系推送到测试中的单个位置。
Notice that this is not the same as the factory function design in section 3.4.2. This factory function resides in our tests; the other was in our production code. This one is for test maintainability, and it can work with object-oriented and functional production code because it hides how the function or object is being created or configured. It’s an abstraction layer in our tests, so we can push the dependency on how a function or object is created or configured into a single place in our tests.
Right now, our class constructor takes in a function as the second parameter:
构造函数(规则,dayOfWeekFn){
this.rules = 规则;
这。每周日=每周日 Fn ;
}constructor(rules, dayOfWeekFn) {
this.rules = rules;
this.dayOfWeek = dayOfWeekFn;
}
让我们在面向对象的设计中更进一步,使用对象而不是函数作为参数。这需要我们做一些跑腿工作:重构代码。
Let’s go one step up in our object-oriented design and use an object instead of a function as our parameter. This requires us to do a bit of legwork: refactor the code.
首先,我们将创建一个名为 time-provider.js 的新文件,其中包含依赖于 moment.js 的真实对象。该对象将被设计为具有一个名为的函数getDay():
First, we’ll create a new file called time-provider.js, which will contain our real object that has a dependency on moment.js. The object will be designed to have a single function called getDay():
从“时刻”导入时刻;
const RealTimeProvider = () => {
this.getDay = () => moment().day()
};import moment from "moment";
const RealTimeProvider = () => {
this.getDay = () => moment().day()
};
Next, we’ll change the parameter usage to use an object with a function:
const 周日 = 0,周一 = 1,周六 = 6;
类密码验证器 {
构造函数(规则,时间提供者){
this.rules = 规则;
这。时间提供者=时间提供者;
}
验证(输入){
if ([周六、周日].includes(this.timeProvider.getDay())) {
抛出新的错误(“这是周末!”);
}
...
}const SUNDAY = 0, MONDAY = 1, SATURDAY = 6;
class PasswordVerifier {
constructor(rules, timeProvider) {
this.rules = rules;
this.timeProvider = timeProvider;
}
verify(input) {
if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
throw new Error("It's the weekend!");
}
...
}
最后,让我们为任何需要我们实例的人PasswordVerifier提供默认情况下使用实时提供程序进行预配置的能力。我们将使用一个新函数来完成此passwordVerifierFactory操作,任何需要验证程序实例的生产代码都需要使用该函数:
Finally, let’s give whoever needs an instance of our PasswordVerifier the ability to get it preconfigured with the real time provider by default. We’ll do this with a new passwordVerifierFactory function that any production code that needs a verifier instance will need to use:
const passwordVerifierFactory = (规则) => {
返回新的PasswordVerifier(new RealTimeProvider())
};const passwordVerifierFactory = (rules) => {
return new PasswordVerifier(new RealTimeProvider())
};
The following listing shows the entire piece of new code.
Listing 3.12 Injecting an object
从“时刻”导入时刻;
const RealTimeProvider = () => {
this.getDay = () => moment().day()
};
const 周日 = 0,周一 = 1,周六 = 6;
类密码验证器 {
构造函数(规则,时间提供者){
this.rules = 规则;
这。时间提供者=时间提供者;
}
验证(输入){
if ([星期六, 星期日].includes( this.timeProvider.getDay() )) {
抛出新的错误(“这是周末!”);
}
常量错误=[];
//更多代码在这里..
返回错误;
};
}
const passwordVerifierFactory = (规则) => {
返回新的PasswordVerifier(new RealTimeProvider())
};import moment from "moment";
const RealTimeProvider = () => {
this.getDay = () => moment().day()
};
const SUNDAY = 0, MONDAY=1, SATURDAY = 6;
class PasswordVerifier {
constructor(rules, timeProvider) {
this.rules = rules;
this.timeProvider = timeProvider;
}
verify(input) {
if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
throw new Error("It's the weekend!");
}
const errors = [];
//more code goes here..
return errors;
};
}
const passwordVerifierFactory = (rules) => {
return new PasswordVerifier(new RealTimeProvider())
};
我们如何在测试中处理这种类型的设计,我们需要注入一个假对象,而不是一个假函数?我们首先会手动执行此操作,因此您会发现这没什么大不了的。稍后,我们将让框架帮助我们,但你会发现,有时手动编码假对象实际上可以使你的测试比使用框架更具可读性,例如 Jasmine、Jest 或 Sinon(我们将在第 1 章中介绍这些框架) 5)。
How can we handle this type of design in our tests, where we need to inject a fake object, instead of a fake function? We’ll do this manually at first, so you can see that it’s not a big deal. Later, we’ll let frameworks help us, but you’ll see that sometimes hand-coding fake objects can actually make your test more readable than using a framework, such as Jasmine, Jest, or Sinon (we’ll cover those in chapter 5).
首先,在我们的测试文件中,我们将创建一个新的假对象,它与我们的实时提供程序具有相同的函数签名,但它将由我们的测试控制。在这种情况下,我们将只使用构造函数模式:
First, in our test file, we’ll create a new fake object that has the same function signature as our real time provider, but it will be controllable by our tests. In this case, we’ll just use a constructor pattern:
函数 FakeTimeProvider( fakeDay ) {
this.getDay = 函数 () {
返回假日;
}
}function FakeTimeProvider(fakeDay) {
this.getDay = function () {
return fakeDay;
}
}
注意如果您使用更面向对象的风格,您可能会选择创建一个继承公共接口的简单类。我们将在本章稍后部分介绍这一点。
Note If you are working in a more object-oriented style, you might choose to create a simple class that inherits from a common interface. We’ll cover that a bit later in the chapter.
FakeTimeProvider接下来,我们将在测试中构建并将其注入到verifier测试中:
Next, we’ll construct the FakeTimeProvider in our tests and inject it into the verifier under test:
描述('验证者', () => {
test('周末,抛出异常', () => {
常量验证器 =
新的PasswordVerifier([],新的FakeTimeProvider(SUNDAY) );
Expect(()=> verifier.verify('任何东西'))
.toThrow("周末到了!");
});describe('verifier', () => {
test('on weekends, throws exception', () => {
const verifier =
new PasswordVerifier([], new FakeTimeProvider(SUNDAY));
expect(()=> verifier.verify('anything'))
.toThrow("It's the weekend!");
});
Here’s what the full test file looks like.
Listing 3.13 Creating a handwritten stub object
函数FakeTimeProvider (fakeDay) {
this.getDay = 函数 () {
返回假日;
}
}
描述('验证者', () => {
test('类构造函数:周末,抛出异常', () => {
常量验证器 =
新的PasswordVerifier([],新的FakeTimeProvider(SUNDAY) );
Expect(() => verifier.verify('任何东西'))
.toThrow("周末到了!");
});
});function FakeTimeProvider(fakeDay) {
this.getDay = function () {
return fakeDay;
}
}
describe('verifier', () => {
test('class constructor: on weekends, throws exception', () => {
const verifier =
new PasswordVerifier([], new FakeTimeProvider(SUNDAY));
expect(() => verifier.verify('anything'))
.toThrow("It's the weekend!");
});
});
该代码之所以有效,是因为默认情况下 JavaScript 是一种非常宽松的语言。就像 Ruby 或 Python 一样,您可以避免使用鸭子类型。鸭子类型是指如果它像鸭子一样走路并且像鸭子一样说话,我们就会像鸭子一样对待它。在这种情况下,真实对象和假对象都实现相同的功能,即使它们是完全不同的对象。我们可以简单地发送一个来代替另一个,并且生产代码应该可以接受。
This code works because JavaScript, by default, is a very permissive language. Much like Ruby or Python, you can get away with duck typing things. Duck typing refers to the idea that if it walks like a duck and it talks like a duck, we’ll treat it like a duck. In this case, the real object and fake object both implement the same function, even though they are completely different objects. We can simply send one in place of the other, and the production code should be OK with this.
当然,我们只会知道这没问题,并且我们在运行时没有犯任何错误或遗漏有关函数签名的任何内容。如果我们想要更有信心,我们可以以更类型安全的方式尝试。
Of course, we’ll only know that this is OK and that we didn’t make any mistakes or miss anything regarding the function signatures at run time. If we want a bit more confidence, we can try it in a more type-safe manner.
我们可以一步到位进一步,以及,如果我们使用 TypeScript 或强类型语言(例如 Java 或 C#),请开始使用接口来表示依赖项所扮演的角色。我们可以创建某种契约,真实对象和假对象都必须在编译器级别遵守。
We can take things one step further, and, if we’re using TypeScript or a strongly typed language such as Java or C#, start using interfaces to denote the roles that our dependencies play. We can create a contract of sorts that both real objects and fake objects will have to abide by at the compiler level.
首先,我们将定义新接口(请注意,这现在是 TypeScript 代码):
First, we’ll define our new interface (notice that this is now TypeScript code):
导出接口 TimeProviderInterface {
getDay():数字;
}export interface TimeProviderInterface {
getDay(): number;
}
其次,我们将定义一个实时提供程序,在我们的生产代码中实现我们的接口,如下所示:
Second, we’ll define a real time provider that implements our interface in our production code like this:
从“时刻”导入*作为时刻;
从“./time-provider-interface”导入{TimeProviderInterface};
导出类 RealTimeProvider实现 TimeProviderInterface {
getDay(): 数字 {
返回时刻().day();
}
}import * as moment from "moment";
import {TimeProviderInterface} from "./time-provider-interface";
export class RealTimeProvider implements TimeProviderInterface {
getDay(): number {
return moment().day();
}
}
第三,我们将更新 our 的构造函数PasswordVerifier以获取新类型的依赖项TimeProviderInterface,而不是使用 的参数类型RealTimeProvider。我们抽象了时间提供者的角色,并声明我们不关心传递的对象是什么,只要它响应该角色的接口即可:
Third, we’ll update the constructor of our PasswordVerifier to take a dependency of our new TimeProviderInterface type, instead of having a parameter type of RealTimeProvider. We’re abstracting away the role of a time provider and declaring that we don’t care what object is being passed, as long as it answers to this role’s interface:
导出类PasswordVerifier {
private _ timeProvider: TimeProviderInterface;
构造函数(规则:any[],timeProvider:TimeProviderInterface){
this._timeProvider = timeProvider;
}
验证(输入:字符串):字符串[] {
const isWeekened = [周日、周六]
.filter(x => x === this._ timeProvider.getDay () )
.长度> 0;
if (isWeekened) {
抛出新的错误(“这是周末!”)
}
// 这里有更多逻辑
返回 [];
}
}export class PasswordVerifier {
private _timeProvider: TimeProviderInterface;
constructor(rules: any[], timeProvider: TimeProviderInterface) {
this._timeProvider = timeProvider;
}
verify(input: string):string[] {
const isWeekened = [SUNDAY, SATURDAY]
.filter(x => x === this._timeProvider.getDay())
.length > 0;
if (isWeekened) {
throw new Error("It's the weekend!")
}
// more logic goes here
return [];
}
}
现在我们有了一个定义“鸭子”外观的接口,我们可以在测试中实现我们自己的鸭子。它看起来很像之前测试的代码,但有一个很大的区别:它将经过编译器检查以确保方法签名的正确性。
Now that we have an interface that defines what a “duck” looks like, we can implement a duck of our own in our tests. It’s going to look a lot like the previous test’s code, but it will have one strong difference: it will be compiler checked to ensure the correctness of the method signatures.
Here’s what our fake time provider looks like in our test file:
类 FakeTimeProvider实现 TimeProviderInterface {
fakeDay:数字;
getDay(): 数字 {
返回 this.fakeDay;
}
}class FakeTimeProvider implements TimeProviderInterface {
fakeDay: number;
getDay(): number {
return this.fakeDay;
}
}
描述('带有接口的密码验证器',()=> {
test('周末,抛出异常', () => {
const StubTimeProvider = new FakeTimeProvider();
StubTimeProvider.fakeDay = 周日;
const verifier = new PasswordVerifier( [] , stubTimeProvider) ;
Expect(() => verifier.verify('任何东西'))
.toThrow("周末到了!");
});
});describe('password verifier with interfaces', () => {
test('on weekends, throws exceptions', () => {
const stubTimeProvider = new FakeTimeProvider();
stubTimeProvider.fakeDay = SUNDAY;
const verifier = new PasswordVerifier([], stubTimeProvider);
expect(() => verifier.verify('anything'))
.toThrow("It's the weekend!");
});
});
The following listing shows all the code together.
Listing 3.14 Extracting a common interface in production code
导出接口 TimeProviderInterface { getDay(): number; }
导出类 RealTimeProvider 实现 TimeProviderInterface {
getDay(): 数字 {
返回时刻().day();
}
}
导出类密码验证器{
私有_timeProvider:TimeProvider接口;
构造函数(规则:any[],timeProvider:TimeProviderInterface){
this._timeProvider = timeProvider;
}
验证(输入:字符串):字符串[] {
const isWeekend = [周日、周六]
.filter(x => x === this._timeProvider.getDay())
.长度>0;
如果(是周末){
抛出新的错误(“这是周末!”)
}
返回 [];
}
}
类 FakeTimeProvider 实现 TimeProviderInterface{
fakeDay:数字;
getDay(): 数字 {
返回 this.fakeDay;
}
}
描述('带有接口的密码验证器',()=> {
test('周末,抛出异常', () => {
const StubTimeProvider = new FakeTimeProvider();
StubTimeProvider.fakeDay = 周日;
constverifier = newPasswordVerifier([],stubTimeProvider);
Expect(() => verifier.verify('任何东西'))
.toThrow("周末到了!");
});
});export interface TimeProviderInterface { getDay(): number; }
export class RealTimeProvider implements TimeProviderInterface {
getDay(): number {
return moment().day();
}
}
export class PasswordVerifier {
private _timeProvider: TimeProviderInterface;
constructor(rules: any[], timeProvider: TimeProviderInterface) {
this._timeProvider = timeProvider;
}
verify(input: string):string[] {
const isWeekend = [SUNDAY, SATURDAY]
.filter(x => x === this._timeProvider.getDay())
.length>0;
if (isWeekend) {
throw new Error("It's the weekend!")
}
return [];
}
}
class FakeTimeProvider implements TimeProviderInterface{
fakeDay: number;
getDay(): number {
return this.fakeDay;
}
}
describe('password verifier with interfaces', () => {
test('on weekends, throws exceptions', () => {
const stubTimeProvider = new FakeTimeProvider();
stubTimeProvider.fakeDay = SUNDAY;
const verifier = new PasswordVerifier([], stubTimeProvider);
expect(() => verifier.verify('anything'))
.toThrow("It's the weekend!");
});
});
我们现在已经从纯粹的函数式设计完全转变为强类型、面向对象的设计。哪一个最适合您的团队和您的项目?没有单一的答案。我将在第 8 章中详细讨论设计。在这里,我主要想表明,无论您最终选择哪种设计,注入模式基本保持不变。它只是通过不同的词汇或语言特征来启用。
We’ve now made a full transition from a purely functional design into a strongly typed, object-oriented design. Which is best for your team and your project? There’s no single answer. I’ll talk more about design in chapter 8. Here, I mainly wanted to show that whatever design you end up choosing, the pattern of injection remains largely the same. It is just enabled with different vocabulary or language features.
注入的能力使我们能够模拟在现实生活中几乎不可能测试的事情。这就是桩的想法最闪耀的地方。我们可以告诉我们的桩返回假值,甚至模拟代码中的异常,以查看它如何处理由依赖项引起的错误。注射使这成为可能。注入还使我们的测试更具可重复性、一致性和可信性,我将在本书的第三部分讨论可信性。在下一章中,我们将研究模拟对象并了解它们与桩有何不同。
It’s the ability to inject that enables us to simulate things that would be practically impossible to test in real life. That’s where the idea of stubs shines the most. We can tell our stubs to return fake values or even to simulate exceptions in our code, to see how it handles errors arising from dependencies. Injection makes this possible. Injection has also made our tests more repeatable, consistent, and trustworthy, and I’ll talk about trustworthiness in the third part of this book. In the next chapter, we’ll look at mock objects and see how they differ from stubs.
Test double is an overarching term that describes all kinds of non-production-ready, fake dependencies in tests. There are five variations on test doubles that can be grouped into just two types: mocks and stubs.
模拟有助于模拟和检查传出依赖项:代表我们工作单元的退出点的依赖项。被测系统 (SUT) 调用传出依赖项来更改这些依赖项的状态。桩有助于模拟传入的依赖关系:SUT 调用此类依赖关系来获取输入数据。
Mocks help emulate and examine outgoing dependencies: dependencies that represent an exit point of our unit of work. The system under test (SUT) calls outgoing dependencies to change the state of those dependencies. Stubs help emulate incoming dependencies: the SUT makes calls to such dependencies to get input data.
Stubs help replace an unreliable dependency with a fake, reliable one and thus avoid test flakiness.
There are multiple ways to inject a stub into a unit of work:
Function as parameter—Injecting a function instead of a plain value.
Partial application (currying) and factory functions—Creating a function that returns another function with some of the context baked in. This context may include the dependency you replaced with a stub.
Module injection—Replacing a module with a fake one with the same API. This approach is fragile. You may need a lot of refactoring if the module you are faking changes its API in the future.
Constructor function—This is mostly the same as partial application.
Class constructor injection—This is a common object-oriented technique where you inject a dependency via a constructor.
Object as parameter (aka duck typing)—In JavaScript, you can inject any dependency in place of the required one as long as that dependency implements the same functions.
Common interface as parameter—This is the same as object as parameter, but it involves a check during compile time. For this approach, you need a strongly typed language like TypeScript.
在上一章中,我们解决了测试依赖于其他对象的代码能否正确运行的问题。我们使用桩来确保被测代码收到所需的所有输入,以便我们可以单独测试工作单元。
In the previous chapter, we solved the problem of testing code that depends on other objects to run correctly. We used stubs to make sure that the code under test received all the inputs it needed so that we could test the unit of work in isolation.
到目前为止,您只编写了针对工作单元可以具有的三种退出点类型中的前两种的测试:返回值和更改系统状态(您可以在第 1 章中阅读有关这些类型的更多信息) )。在本章中,我们将了解如何测试第三种类型的退出点——对第三方函数、模块或对象的调用。这很重要,因为我们的代码通常取决于我们无法控制的事情。知道如何检查该类型的代码是单元测试领域的一项重要技能。基本上,我们将找到方法来证明我们的工作单元最终会调用我们无法控制的函数,并确定哪些值作为参数发送。
So far, you’ve only written tests that work against the first two of the three types of exit points a unit of work can have: returning a value and changing the state of the system (you can read more about these types in chapter 1). In this chapter, we’ll look at how you can test the third type of exit point—a call to a third-party function, module, or object. This is important, because often we’ll have code that depends on things we can’t control. Knowing how to check that type of code is an important skill in the world of unit testing. Basically, we’ll find ways to prove that our unit of work ends up calling a function that we don’t control and identify what values were sent as arguments.
到目前为止我们看到的方法在这里不起作用,因为第三方函数通常没有专门的 API 来允许我们检查它们是否被正确调用。相反,他们将其操作内部化以提高清晰度和可维护性。那么,如何测试您的工作单元是否与第三方函数正确交互?你使用模拟。
The approaches we’ve looked at so far won’t do here, because third-party functions usually don’t have specialized APIs that allow us to check if they were called correctly. Instead, they internalize their operations for clarity and maintainability. So, how can you test that your unit of work interacts with third-party functions correctly? You use mocks.
交互测试正在检查工作单元如何与超出其控制的依赖项交互并向其发送消息(即调用函数)。模拟函数或对象用于断言已正确调用外部依赖项。
Interaction testing is checking how a unit of work interacts with and sends messages (i.e., calls functions) to a dependency beyond its control. Mock functions or objects are used to assert that a call was made correctly to an external dependency.
让我们回顾一下模拟和桩之间的区别,正如我们在第 3 章中介绍的那样。主要区别在于信息流:
Let’s recall the differences between mocks and stubs as we covered them in chapter 3. The main difference is in the flow of information:
模拟——用于打破传出依赖关系。模拟是我们断言在测试中调用的假模块、对象或函数。模拟代表单元测试中的退出点。如果我们不对它进行断言,它就不会被用作模拟。
出于可维护性和可读性的原因,每个测试不超过一个模拟是正常的。(我们将在本书有关编写可维护测试的第 3 部分中详细讨论这一点。)
Mock—Used to break outgoing dependencies. Mocks are fake modules, objects, or functions that we assert were called in our tests. A mock represents an exit point in a unit test. If we don’t assert on it, it’s not used as a mock.
It is normal to have no more than a single mock per test, for maintainability and readability reasons. (We’ll discuss this more in part 3 of this book about writing maintainable tests.)
桩——用于破坏传入的依赖关系。桩是假模块、对象或函数,它们向被测代码提供假行为或数据。我们不会对它们进行断言,并且我们可以在一次测试中拥有许多桩。
桩代表路径点,而不是退出点,因为数据或行为流入工作单元。它们是交互点,但并不代表工作单元的最终结果。相反,它们是实现我们关心的最终结果的过程中的交互,因此我们不会将它们视为退出点。
Stub—Used to break incoming dependencies. Stubs are fake modules, objects, or functions that provide fake behavior or data to the code under test. We do not assert against them, and we can have many stubs in a single test.
Stubs represent waypoints, not exit points, because the data or behavior flows into the unit of work. They are points of interaction, but they do not represent an ultimate outcome of the unit of work. Instead, they are an interaction on the way to achieving the end result we care about, so we don’t treat them as exit points.
Figure 4.1 shows these two side by side.
图 4.1 左侧是通过调用依赖项实现的退出点。右侧,依赖项提供间接输入或行为,而不是退出点。
Figure 4.1 On the left, an exit point that is implemented as invoking a dependency. On the right, the dependency provides indirect input or behavior and is not an exit point.
让我们看一个我们无法控制的依赖项的退出点的简单示例:调用记录器。
Let’s look at a simple example of an exit point to a dependency that we do not control: calling a logger.
让我们以这个密码验证器函数作为我们的起始示例,我们假设我们有一个复杂的记录器(这是一个具有更多函数和参数的记录器,因此界面可能会带来更多挑战)。我们函数的要求之一是在验证通过或失败时调用记录器,如下所示。
Let’s take this Password Verifier function as our starting example, and we’ll assume we have a complicated logger (which is a logger that has more functions and parameters, so the interface may present more of a challenge). One of the requirements of our function is to call the logger when verification has passed or failed, as follows.
Listing 4.1 Depending directly on a complicated logger
// 使用传统的注入技术不可能伪造
const log = require('./complicated-logger');
const verifyPassword = (输入, 规则) => {
const 失败 = 规则
.map(规则=>规则(输入))
.filter(结果 => 结果 === false);
if (失败.count === 0) {
// 使用传统的注入技术进行测试
log.info('通过'); ❶
返回true;// ❶
}
//无法用传统的注入技术进行测试
log.info('失败'); // ❶
返回 false; // ❶
};
const info = (text) => {
console.log(`INFO: ${text}`);
};
常量调试=(文本)=> {
console.log(`调试:${text}`);
};// impossible to fake with traditional injection techniques
const log = require('./complicated-logger');
const verifyPassword = (input, rules) => {
const failed = rules
.map(rule => rule(input))
.filter(result => result === false);
if (failed.count === 0) {
// to test with traditional injection techniques
log.info('PASSED'); ❶
return true; // ❶
}
//impossible to test with traditional injection techniques
log.info('FAIL'); // ❶
return false; // ❶
};
const info = (text) => {
console.log(`INFO: ${text}`);
};
const debug = (text) => {
console.log(`DEBUG: ${text}`);
};
图 4.2 说明了这一点。我们的verifyPassword函数是工作单元的入口点,总共有两个出口点:一个返回值,另一个调用log.info().
Figure 4.2 illustrates this. Our verifyPassword function is the entry point to the unit of work, and we have a total of two exit points: one that returns a value, and another that calls log.info().
图 4.2 密码验证器的入口点是函数verifyPassword。一个退出点返回一个值,另一个退出点调用log.info().
Figure 4.2 The entry point to the Password Verifier is the verifyPassword function. One exit point returns a value, and the other calls log.info().
不幸的是,我们无法logger通过使用任何传统方式来验证它的调用,或者不使用一些 Jest 技巧,我通常仅在没有其他选择的情况下使用这些技巧,因为它们往往会降低测试的可读性并且更难以维护(稍后会详细介绍)本章)。
Unfortunately, we cannot verify that logger was called by using any traditional means, or without using some Jest tricks, which I usually use only if there’s no other choice, as they tend to make tests less readable and harder to maintain (more on that later in this chapter).
让我们对依赖项做我们喜欢做的事情:抽象它们。有很多方法可以在我们的代码中创建接缝。记住,接缝是两段代码相遇的地方——我们可以用它们来注入假东西。表 4.1 列出了抽象依赖关系的最常见方法。
Let’s do what we like to do with dependencies: abstract them. There are many ways to create a seam in our code. Remember, seams are places where two pieces of code meet—we can use them to inject fake things. Table 4.1 lists the most common ways to abstract dependencies.
Table 4.1 Techniques for injecting fakes
我们开始这一旅程的最明显的方法是在我们的测试代码中引入一个新参数。
The most obvious way we can start this journey is by introducing a new parameter into our code under test.
Listing 4.2 Mock logger parameter injection
const verifyPassword2 = (输入, 规则,记录器) => {
const 失败 = 规则
.map(规则=>规则(输入))
.filter(结果 => 结果 === false);
if (失败.length === 0) {
logger.info('通过');
返回真;
}
logger.info('失败');
返回假;
};const verifyPassword2 = (input, rules, logger) => {
const failed = rules
.map(rule => rule(input))
.filter(result => result === false);
if (failed.length === 0) {
logger.info('PASSED');
return true;
}
logger.info('FAIL');
return false;
};
下面的清单显示了我们如何使用简单的闭包机制为此编写最简单的测试。
The following listing shows how we could write the simplest of tests for this, using a simple closure mechanism.
Listing 4.3 Handwritten mock object
描述('带有记录器的密码验证器', () => {
描述('当所有规则都通过时', () => {
it('通过 PASSED 调用记录器', () => {
let write = '';
const mockLog = {
info: (text) => {
write = text;
}
};
verifyPassword2('任何内容', [], mockLog );
期望(写).toMatch(/通过/);
});
});
});describe('password verifier with logger', () => {
describe('when all rules pass', () => {
it('calls the logger with PASSED', () => {
let written = '';
const mockLog = {
info: (text) => {
written = text;
}
};
verifyPassword2('anything', [], mockLog);
expect(written).toMatch(/PASSED/);
});
});
});
首先请注意,我们命名变量mockXXX(mockLog在本例中)是为了表示我们在测试中有一个模拟函数或对象。我使用这种命名约定是因为我希望您作为测试的读者知道您应该在测试结束时针对该模拟进行断言(也称为验证)。这种命名方法消除了读者的意外因素,并使测试更加可预测。仅对实际模拟的事物使用此命名约定。
Notice first that we are naming the variable mockXXX (mockLog in this example) to denote the fact that we have a mock function or object in the test. I use this naming convention because I want you, as a reader of the test, to know that you should expect an assert (also known as verification) against that mock at the end of the test. This naming approach removes the element of surprise for the reader and makes the test much more predictable. Only use this naming convention for things that are actually mocks.
让书面='';
常量模拟日志 = {
信息:(文本)=> {
书面=文字;
}
};let written = '';
const mockLog = {
info: (text) => {
written = text;
}
};
它只有一个功能,模仿记录器功能的签名info。然后它会保存传递给它的参数 ( text),以便我们可以断言它在稍后的测试中被调用。如果written变量具有正确的文本,则证明我们的函数被调用,这意味着我们已经证明我们的工作单元正确调用了退出点。
It only has one function, which mimics the signature of the logger’s info function. It then saves the parameter being passed to it (text) so that we can assert that it was called later in the test. If the written variable has the correct text, this proves that our function was called, which means we have proven that the exit point is invoked correctly from our unit of work.
另一方面verifyPassword2,我们所做的重构很常见。这与我们在上一章中所做的几乎相同,我们提取了一个桩作为依赖项。在重构和在应用程序代码中引入接缝方面,桩和模拟通常以相同的方式处理。
On the verifyPassword2 side, the refactoring we did is pretty common. It’s pretty much the same as we did in the previous chapter, where we extracted a stub as a dependency. Stubs and mocks are often treated the same way in terms of refactoring and introducing seams in our application’s code.
What did this simple refactoring into a parameter provide us with?
我们不再需要在测试的代码中显式导入(通过require) 。logger这意味着,如果我们更改记录器的真正依赖项,则被测试的代码将少一个需要更改的理由。
We do not need to explicitly import (via require) the logger in our code under test anymore. That means that if we ever change the real dependency of the logger, the code under test will have one less reason to change.
现在,我们可以将我们选择的任何记录器注入到被测代码中,只要它符合相同的接口(或至少具有该info方法)。这意味着我们可以提供一个模拟记录器来为我们执行命令:模拟记录器帮助我们验证它是否被正确调用。
We now have the ability to inject any logger of our choosing into the code under test, as long as it lives up to the same interface (or at least has the info method). This means that we can provide a mock logger that does our bidding for us: the mock logger helps us verify that it was called correctly.
注意我们的模拟对象仅模仿 的接口的一部分logger(它缺少函数debug)这一事实是鸭子类型的一种形式。我在第三章讨论了这个想法:如果它像鸭子一样走路,并且像鸭子一样说话,那么我们可以将它用作假对象。
Note The fact that our mock object only mimics a part of the logger’s interface (it’s missing the debug function) is a form of duck typing. I discussed this idea in chapter 3: if it walks like a duck, and it talks like a duck, then we can use it as a fake object.
为什么我如此关心我们对每件事的命名?如果我们无法区分模拟和桩之间的区别,或者我们没有正确命名它们,那么我们最终可能会得到测试多个事物的测试,并且这些测试的可读性较差且难以维护。正确命名事物可以帮助我们避免这些陷阱。
Why do I care so much about what we name each thing? If we can’t tell the difference between mocks and stubs, or we don’t name them correctly, we can end up with tests that are testing multiple things and that are less readable and harder to maintain. Naming things correctly helps us avoid these pitfalls.
鉴于模拟代表了我们工作单元的要求(“它调用记录器”,“它发送电子邮件”等),并且桩代表传入的信息或行为(“数据库查询返回 false”,“这个特定配置会引发错误”),我们可以设置一个简单的经验法则:在测试中拥有多个桩应该没问题,但您通常不希望每个测试有多个模拟,因为这意味着您在一次测试中测试了多个需求。
Given that a mock represents a requirement from our unit of work (“it calls the logger,” “it sends an email,” etc.) and that a stub represents incoming information or behavior (“the database query returns false,” “this specific configuration throws an error”), we can set a simple rule of thumb: It should be OK to have multiple stubs in a test, but you don’t usually want to have more than a single mock per test, because that would mean you’re testing more than one requirement in a single test.
如果我们不能(或不会)区分事物(命名是关键),我们最终可能会在每个测试中进行多个模拟或断言我们的桩,这两者都会对我们的测试产生负面影响。保持命名一致给我们带来以下好处:
If we can’t (or won’t) differentiate between things (naming is key to that), we can end up with multiple mocks per test or asserting our stubs, both of which can have negative effects on our tests. Keeping naming consistent gives us the following benefits:
可读性——您的测试名称将变得更加通用且难以理解。您希望人们能够阅读测试的名称并了解其中发生或测试的所有内容,而无需阅读测试的代码。
Readability—Your test name will become much more generic and harder to understand. You want people to be able to read the name of the test and know everything that happens or is tested inside of it, without needing to read the test’s code.
可维护性——如果您不区分模拟和桩,您可以在没有注意到甚至不关心的情况下对桩进行断言。这对您产生的价值很小,并且增加了测试和内部生产代码之间的耦合。断言您查询数据库就是一个很好的例子。与其测试数据库查询是否返回某个值,不如测试在更改数据库输入后应用程序的行为是否发生变化。
Maintainability—You could, without noticing or even caring, assert against stubs if you don’t differentiate between mocks and stubs. This produces little value to you and increases the coupling between your tests and internal production code. Asserting that you queried a database is a good example of this. Instead of testing that a database query returns some value, it would be much better to test that the application’s behavior changes after we change the input from the database.
信任- 如果在单个测试中有多个模拟(要求),并且第一个模拟验证使测试失败,则大多数测试框架将不会执行测试的其余部分(在失败的断言行下方),因为已引发异常。这意味着其他模拟未经过验证,您将无法从中获得结果。
Trust—If you have multiple mocks (requirements) in a single test, and the first mock verification fails the test, most test frameworks won’t execute the rest of the test (below the failing assert line) because an exception has been thrown. This means that the other mocks aren’t verified, and you won’t get the results from them.
为了强调最后一点,想象一下一位医生只看到了病人 30% 的症状,但仍然需要做出决定——他们可能会做出错误的治疗决定。如果您看不到所有错误在哪里,或者有两件事失败而不是只有一个(因为其中一个在第一次失败后被隐藏),那么您更有可能修复错误的问题或修复它错误的地方。
To drive the last point home, imagine a doctor who only sees 30% of their patient’s symptoms, but still needs to make a decision—they might make the wrong decision about treatment. If you can’t see where all the bugs are, or that two things are failing instead of just one (because one of them is hidden after the first failure), you’re more likely to fix the wrong thing or to fix it in the wrong place.
Gerard Meszaros的XUnit 测试模式(Addison-Wesley,2007)将这种情况称为断言轮盘赌(http://xunitpatterns.com/Assertion%20Roulette.xhtml)。我喜欢这个名字。这真是一场赌博。你开始注释掉测试中的代码行,随之而来的是很多乐趣(可能还包括酒精)。
XUnit Test Patterns (Addison-Wesley, 2007), by Gerard Meszaros, calls this situation assertion roulette (http://xunitpatterns.com/Assertion%20Roulette.xhtml). I like this name. It’s quite a gamble. You start commenting out lines of code in your test, and lots of fun ensues (and possibly alcohol).
我在前一章中介绍了模块化依赖注入,但现在我们将了解如何使用它来注入模拟对象并模拟它们的答案。
I covered modular dependency injection in the previous chapter, but now we’re going to look at how we can use it to inject mock objects and simulate answers on them.
让我们看一个比之前看到的稍微复杂一些的例子。在这种情况下,我们的verifyPassword函数依赖于两个外部依赖项:
Let’s look at a slightly more complicated example than we saw before. In this scenario, our verifyPassword function depends on two external dependencies:
配置服务提供所需的日志记录级别。通常这种类型的代码会被移动到一个特殊的记录器模块中,但出于本书示例的目的,我将调用logger.info和 的逻辑logger.debug直接放在测试的代码中。
The configuration service provides the logging level that is required. Usually this type of code would be moved into a special logger module, but for the purposes of this book’s examples, I’m putting the logic that calls logger.info and logger.debug directly in the code under test.
Listing 4.4 A hard modular dependency
const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");
常量日志=(文本)=> {
if ( getLogLevel() === "信息") {
信息(文本);
}
if ( getLogLevel() === "调试") {
调试(文本);
}
};
const verifyPassword = (输入, 规则) => {
const 失败 = 规则
.map((规则) => 规则(输入))
.filter((结果) => 结果 === false);
if (失败.length === 0) {
日志(“通过”); ❶
返回真;
}
日志(“失败”); ❶
返回假;
};
模块. 导出 = {
验证密码,
};const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");
const log = (text) => {
if (getLogLevel() === "info") {
info(text);
}
if (getLogLevel() === "debug") {
debug(text);
}
};
const verifyPassword = (input, rules) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
log("PASSED"); ❶
return true;
}
log("FAIL"); ❶
return false;
};
module.exports = {
verifyPassword,
};
假设我们在调用记录器时意识到有一个错误。我们改变了检查失败的方式,现在PASSED当失败次数为正而不是零时,我们会调用记录器并返回结果。我们如何通过单元测试证明这个错误存在,或者我们已经修复了它?
Let’s assume that we realized we have a bug when we call the logger. We’ve changed the way we check for failures, and now we call the logger with a PASSED result when the number of failures is positive instead of zero. How can we prove that this bug exists, or that we’ve fixed it, with a unit test?
我们的问题是我们直接在代码中导入(或要求)模块。如果我们想要替换记录器模块,我们必须替换文件或通过 Jest 的 API 执行一些其他黑魔法。我通常不建议这样做,因为在处理代码时使用这些技术会导致比平常更多的痛苦和痛苦。
Our problem here is that we are importing (or requiring) the modules directly in our code. If we want to replace the logger module, we have to either replace the file or perform some other dark magic through Jest’s API. I wouldn’t recommend that usually, because using these techniques leads to more pain and suffering than is usual when dealing with code.
我们可以将模块依赖项抽象为它们自己的对象,并允许模块的用户按如下方式替换该对象。
We can abstract away the module dependencies into their own object and allow the user of our module to replace that object as follows.
Listing 4.5 Refactoring to a modular injection pattern
const originDependency = { ❶
log: require('./complicated-logger'), ❶
}; ❶
让依赖项 = { ...originalDependency }; ❷
const ResetDependency = () => { ❸
dependency = { ...originalDependencies }; ❸
}; ❸
constjectDependency = (fakes) => { ❹
Object.assign(dependency, fakes); ❹
}; ❹
const verifyPassword = (输入, 规则) => {
const 失败 = 规则
.map(规则=>规则(输入))
.filter(结果 => 结果 === false);
if (失败.length === 0) {
dependency.log.info('通过');
返回真;
}
dependency.log.info('失败');
返回假;
};
模块. 导出 = {
验证密码, ❺
注入依赖项, ❺
重置依赖项 ❺
};const originalDependencies = { ❶
log: require('./complicated-logger'), ❶
}; ❶
let dependencies = { ...originalDependencies }; ❷
const resetDependencies = () => { ❸
dependencies = { ...originalDependencies }; ❸
}; ❸
const injectDependencies = (fakes) => { ❹
Object.assign(dependencies, fakes); ❹
}; ❹
const verifyPassword = (input, rules) => {
const failed = rules
.map(rule => rule(input))
.filter(result => result === false);
if (failed.length === 0) {
dependencies.log.info('PASSED');
return true;
}
dependencies.log.info('FAIL');
return false;
};
module.exports = {
verifyPassword, ❺
injectDependencies, ❺
resetDependencies ❺
};
❶ Holding original dependencies
❸ A function that resets the dependencies
❹ A function that overrides the dependencies
❺ Exposing the API to the users of the module
这里有更多的生产代码,而且看起来更复杂,但是如果我们被迫以这种模块化的方式工作,这允许我们以相对简单的方式替换测试中的依赖项。
There’s more production code here, and it seems more complex, but this allows us to replace dependencies in our tests in a relatively easy manner if we are forced to work in such a modular fashion.
该originalDependencies变量将始终保留原始依赖关系,因此我们永远不会在测试之间丢失它们。dependencies是我们的间接层。它默认为原始依赖项,但我们的测试可以指示被测代码用自定义依赖项替换该变量(无需了解有关模块内部的任何信息)。injectDependencies和resetDependencies是模块公开的用于覆盖和重置依赖项的公共 API。
The originalDependencies variable will always hold the original dependencies, so that we never lose them between tests. dependencies is our layer of indirection. It defaults to the original dependencies, but our tests can direct the code under test to replace that variable with custom dependencies (without knowing anything about the internals of the module). injectDependencies and resetDependencies are the public API that the module exposes for overriding and resetting the dependencies.
The following listing shows what a test for modular injection might look like.
Listing 4.6 Testing with modular injection
常量{
验证密码,
注入依赖关系,
重置依赖关系,
} = require("./密码验证器-可注入");
描述(“密码验证器”,()=> {
afterEach(重置依赖关系);
描述(“给定的记录器和传递场景”,()=> {
it("使用 PASS 调用记录器", () => {
让记录=“”;
const mockLog = { 信息: (文本) => (记录 = 文本) };
注入依赖项({日志:mockLog});
验证密码(“任何内容”,[]);
期望(记录)。toMatch(/通过/);
});
});
});const {
verifyPassword,
injectDependencies,
resetDependencies,
} = require("./password-verifier-injectable");
describe("password verifier", () => {
afterEach(resetDependencies);
describe("given logger and passing scenario", () => {
it("calls the logger with PASS", () => {
let logged = "";
const mockLog = { info: (text) => (logged = text) };
injectDependencies({ log: mockLog });
verifyPassword("anything", []);
expect(logged).toMatch(/PASSED/);
});
});
});
只要我们不忘记resetDependencies在每次测试后使用该函数,我们现在就可以很容易地注入模块以进行测试。明显的主要警告是,这种方法要求每个模块公开可以从外部使用的注入和重置函数。这可能适用于您当前的设计限制,也可能不适用于您当前的设计限制,但如果适用,您可以将它们抽象为可重用的函数,并为自己节省大量样板代码。
As long as we don’t forget to use the resetDependencies function after each test, we can now inject modules pretty easily for test purposes. The obvious main caveat is that this approach requires each module to expose inject and reset functions that can be used from the outside. This might or might not work with your current design limitations, but if it does, you can abstract them both into reusable functions and save yourself a lot of boilerplate code.
Let’s jump into a few of the functional styles we can use to inject mocks into our code under test.
让我们实现第 3 章中介绍的柯里化技术,以对记录器执行更具函数式的注入。在下面的清单中,我们将使用lodash,一个促进 JavaScript 中函数式编程的库,无需太多样板代码即可进行柯里化工作。
Let’s implement the currying technique introduced in chapter 3 to perform a more functional-style injection of our logger. In the following listing, we’ll use lodash, a library that facilitates functional programming in JavaScript, to get currying working without too much boilerplate code.
Listing 4.7 Applying currying to our function
const verifyPassword3 = _ .curry( (规则、记录器、输入) => {
const 失败 = 规则
.map(规则=>规则(输入))
.filter(结果 => 结果 === false);
if (失败.length === 0) {
logger.info('通过');
返回真;
}
logger.info('失败');
返回假;
} ) ;const verifyPassword3 = _.curry((rules, logger, input) => {
const failed = rules
.map(rule => rule(input))
.filter(result => result === false);
if (failed.length === 0) {
logger.info('PASSED');
return true;
}
logger.info('FAIL');
return false;
});
唯一的变化是在第一行调用_.curry,并在代码块末尾将其关闭。
The only change is the call to _.curry on the first line, and closing it off at the end of the code block.
The following listing demonstrates what a test for this type of code might look like.
Listing 4.8 Testing a curried function with dependency injection
描述(“密码验证器”,()=> {
描述(“给定的记录器和传递场景”,()=> {
it("使用 PASS 调用记录器", () => {
让记录=“”;
const mockLog = { 信息: (文本) => (记录 = 文本) };
const InjectedVerify = verifyPassword3([],mockLog);
// 这个部分应用的函数可以被传递
// 到代码中的其他地方
// 无需注入记录器
注入验证(“任何东西”);
期望(记录)。toMatch(/通过/);
});
});
});describe("password verifier", () => {
describe("given logger and passing scenario", () => {
it("calls the logger with PASS", () => {
let logged = "";
const mockLog = { info: (text) => (logged = text) };
const injectedVerify = verifyPassword3([], mockLog);
// this partially applied function can be passed around
// to other places in the code
// without needing to inject the logger
injectedVerify("anything");
expect(logged).toMatch(/PASSED/);
});
});
});
我们的测试使用前两个参数调用函数(注入rules和logger依赖项,有效返回部分应用的函数),然后使用injectedVerify最终输入调用返回的函数,从而向读者展示两件事:
Our test invokes the function with the first two arguments (injecting the rules and logger dependencies, effectively returning a partially applied function), and then invokes the returned function injectedVerify with the final input, thus showing the reader two things:
Other than that, it’s pretty much the same as in the previous test.
清单 4.9 是函数式编程设计的另一种变体。我们使用高阶函数,但没有柯里化。您可以看出以下代码不包含柯里化,因为我们始终需要将所有参数作为参数发送给函数才能使其正常工作。
Listing 4.9 is another variation on the functional programming design. We’re using a higher-order function, but without currying. You can tell that the following code does not contain currying because we always need to send in all of the parameters as arguments to the function for it to be able to work correctly.
Listing 4.9 Injecting a mock in a higher-order function
const makeVerifier = (规则, 记录器) => {
return (输入) => { ❶
const 失败 = 规则
.map(规则=>规则(输入))
.filter(结果 => 结果 === false);
if (失败.length === 0) {
logger.info('通过');
返回真;
}
logger.info('失败');
返回假;
};
};const makeVerifier = (rules, logger) => {
return (input) => { ❶
const failed = rules
.map(rule => rule(input))
.filter(result => result === false);
if (failed.length === 0) {
logger.info('PASSED');
return true;
}
logger.info('FAIL');
return false;
};
};
❶ Returning a preconfigured verifier
这次我显式地创建一个工厂函数,该函数返回一个预配置的验证器函数,该函数已在其闭包的依赖项中包含rules和logger。
This time I’m explicitly making a factory function that returns a preconfigured verifier function that already contains the rules and logger in its closure’s dependencies.
现在让我们看一下对此的测试。测试需要首先调用makeVerifier工厂函数,然后调用该函数返回的函数 ( passVerify)。
Now let’s look at the test for this. The test needs to first call the makeVerifier factory function and then call the function that’s returned by that function (passVerify).
Listing 4.10 Testing using a factory function
描述(“高阶工厂函数”,()=> {
描述(“密码验证器”,()=> {
test("给定记录器和传递场景", () => {
让记录=“”;
const mockLog = { 信息: (文本) => (记录 = 文本) };
const passVerify = makeVerifier([],mockLog); ❶
passVerify("任何输入"); ❷
期望(记录)。toMatch(/通过/);
});
});
});describe("higher order factory functions", () => {
describe("password verifier", () => {
test("given logger and passing scenario", () => {
let logged = "";
const mockLog = { info: (text) => (logged = text) };
const passVerify = makeVerifier([], mockLog); ❶
passVerify("any input"); ❷
expect(logged).toMatch(/PASSED/);
});
});
});
❶ Calling the factory function
❷ Calling the resulting function
现在我们已经介绍了一些函数式和模块化样式,让我们看看面向对象的样式。来自面向对象背景的人会对这种类型的方法感到更舒服,而来自功能背景的人会讨厌它。但生活就是要接受人们的差异。
Now that we’ve covered some functional and modular styles, let’s look at the object-oriented styles. People coming from an object-oriented background will feel much more comfortable with this type of approach, and people coming from a functional background will hate it. But life is about accepting people’s differences.
清单 4.11 显示了这种类型的注入在 JavaScript 中基于类的设计中可能是什么样子。类有构造函数,我们使用构造函数来强制类的调用者提供参数。这不是实现这一目标的唯一方法,但它在面向对象的设计中非常常见和有用,因为它使这些参数的要求明确,并且在强类型语言(例如 Java 或 C)以及使用 TypeScript 时实际上是不可否认的。我们希望确保使用我们代码的人都知道正确配置它需要什么。
Listing 4.11 shows what this type of injection might look like in a class-based design in JavaScript. Classes have constructors, and we use the constructor to force the caller of the class to provide parameters. This is not the only way to accomplish that, but it’s very common and useful in an object-oriented design because it makes the requirement of those parameters explicit and practically undeniable in strongly typed languages such as Java or C, and when using TypeScript. We want to make sure whoever uses our code knows what is expected to configure it properly.
Listing 4.11 Class-based constructor injection
类密码验证器 {
_规则;
_记录器;
构造函数(规则,记录器){
this. _规则=规则;
这。_记录器=记录器;
}
验证(输入){
const 失败 = this._rules
.map(规则=>规则(输入))
.filter(结果 => 结果 === false);
if (失败.length === 0) {
这。_ logger.info('通过');
返回真;
}
这。_ logger.info('失败');
返回假;
}
}class PasswordVerifier {
_rules;
_logger;
constructor(rules, logger) {
this._rules = rules;
this._logger = logger;
}
verify(input) {
const failed = this._rules
.map(rule => rule(input))
.filter(result => result === false);
if (failed.length === 0) {
this._logger.info('PASSED');
return true;
}
this._logger.info('FAIL');
return false;
}
}
这只是一个标准类,它接受几个构造函数参数,然后在函数中使用它们verify。以下清单显示了测试的样子。
This is just a standard class that takes a couple of constructor parameters and then uses them inside the verify function. The following listing shows what a test might look like.
Listing 4.12 Injecting a mock logger as a constructor parameter
描述(“使用函数构造函数注入进行鸭子打字”,()=> {
描述(“密码验证器”,()=> {
test("记录器&通过场景,通过 PASSED 调用记录器", () => {
让记录=“”;
const mockLog = { 信息: (文本) => (记录 = 文本) };
const verifier = new PasswordVerifier([],mockLog);
verifier.verify("任意输入");
期望(记录)。toMatch(/通过/);
});
});
}); describe("duck typing with function constructor injection", () => {
describe("password verifier", () => {
test("logger&passing scenario,calls logger with PASSED", () => {
let logged = "";
const mockLog = { info: (text) => (logged = text) };
const verifier = new PasswordVerifier([], mockLog);
verifier.verify("any input");
expect(logged).toMatch(/PASSED/);
});
});
});
模拟注入很简单,就像我们在上一章中看到的桩一样。如果我们使用属性而不是构造函数,则意味着依赖项是可选的。对于构造函数,我们明确表示它们不是可选的。
Mock injection is straightforward, much like with stubs, as we saw in the previous chapter. If we were to use properties rather than a constructor, it would mean that the dependencies are optional. With a constructor, we’re explicitly saying they’re not optional.
在 Java 或 C# 等强类型语言中,通常将伪造的记录器提取为单独的类,如下所示:
In strongly typed languages like Java or C#, it’s common to extract the fake logger as a separate class, like so:
类 FakeLogger {
记录=“”;
信息(文本){
this.logged = 文本;
}
}class FakeLogger {
logged = "";
info(text) {
this.logged = text;
}
}
我们只是info在类中实现该函数,但不记录任何内容,而是将作为参数发送给该函数的值保存在一个公开可见的变量中,我们可以在稍后的测试中再次断言该变量。
We simply implement the info function in the class, but instead of logging anything, we just save the value being sent as a parameter to the function in a publicly visible variable that we can assert again later in our test.
请注意,我没有调用假对象MockLogger或StubLoggerbut FakeLogger。这样我就可以在多个不同的测试中重用此类。在某些测试中,它可能用作桩,而在其他测试中,它可能用作模拟对象。我用“假”这个词来表示任何不真实的东西。此类事情的另一个常见术语是“测试替身”。假货较短,所以我喜欢。
Notice that I didn’t call the fake object MockLogger or StubLogger but FakeLogger. This is so that I can reuse this class in multiple different tests. In some tests, it might be used as a stub, and in others it might be used as a mock object. I use the word “fake” to denote anything that isn’t real. Another common term for this sort of thing is “test double.” Fake is shorter, so I like it.
在我们的测试中,我们将实例化该类并将其作为构造函数参数发送,然后我们将对logged该类的变量进行断言,如下所示:
In our tests, we’ll instantiate the class and send it over as a constructor parameter, and then we’ll assert on the logged variable of the class, like so:
test("记录器 + 通过场景,使用 PASSED 调用记录器", () => {
让记录=“”;
const mockLog = new FakeLogger();
const verifier = new PasswordVerifier([],mockLog);
verifier.verify("任意输入");
期望(mockLog.logged)。toMatch(/通过/);
});test("logger + passing scenario, calls logger with PASSED", () => {
let logged = "";
const mockLog = new FakeLogger();
const verifier = new PasswordVerifier([], mockLog);
verifier.verify("any input");
expect(mockLog.logged).toMatch(/PASSED/);
});
接口在许多面向对象的程序中发挥着重要作用。它们是多态性思想的一种变体:只要一个或多个对象实现相同的接口,就允许它们相互替换。在 JavaScript 和 Ruby 等其他语言中,不需要接口,因为该语言允许鸭子类型的想法,而无需将对象强制转换为特定接口。我不会在这里讨论鸭子类型的优点和缺点。您应该能够以您选择的语言使用您认为合适的任何一种技术。在 JavaScript 中,我们可以转向 TypeScript 来使用接口。我们将使用的编译器或转换器可以帮助确保我们正确使用基于签名的类型。
Interfaces play a large role in many object-oriented programs. They are one variation on the idea of polymorphism: allowing one or more objects to be replaced with one another as long as they implement the same interface. In JavaScript and other languages like Ruby, interfaces are not needed, since the language allows for the idea of duck typing without needing to cast an object to a specific interface. I won’t touch here on the pros and cons of duck typing. You should be able to use either technique as you see fit, in the language of your choice. In JavaScript, we can turn to TypeScript to use interfaces. The compiler, or transpiler, we’ll use can help ensure that we are using types based on their signatures correctly.
清单 4.13 显示了三个代码文件:第一个描述了一个新ILogger接口,第二个描述了SimpleLogger实现该接口的 ,第三个是 our PasswordVerifier,它仅使用该ILogger接口来获取记录器实例。PasswordVerifier不知道被注入的记录器的实际类型。
Listing 4.13 shows three code files: the first describes a new ILogger interface, the second describes a SimpleLogger that implements that interface, and the third is our PasswordVerifier, which uses only the ILogger interface to get a logger instance. PasswordVerifier has no knowledge of the actual type of logger being injected.
Listing 4.13 Production code gets an ILogger interface
导出接口 ILogger { ❶
info(text: string); ❶
} ❶
//此类可能依赖于文件或网络
SimpleLogger 类实现 ILogger { ❷
信息(文本:字符串){
}
}
导出类密码验证器{
私有_规则:任何[];
私有_记录器:ILogger; ❸
构造函数(规则:any[],记录器:ILogger){ ❸
this._rules = 规则;
这。_记录器=记录器; ❸
}
验证(输入:字符串):布尔值{
const 失败 = this._rules
.map(规则=>规则(输入))
.filter(结果 => 结果 === false);
if (失败.length === 0) {
这。_ logger.info('通过');
返回真;
}
这。_ logger.info('失败');
返回假;
}
}export interface ILogger { ❶
info(text: string); ❶
} ❶
//this class might have dependencies on files or network
class SimpleLogger implements ILogger { ❷
info(text: string) {
}
}
export class PasswordVerifier {
private _rules: any[];
private _logger: ILogger; ❸
constructor(rules: any[], logger: ILogger) { ❸
this._rules = rules;
this._logger = logger; ❸
}
verify(input: string): boolean {
const failed = this._rules
.map(rule => rule(input))
.filter(result => result === false);
if (failed.length === 0) {
this._logger.info('PASSED');
return true;
}
this._logger.info('FAIL');
return false;
}
}
❶ A new interface, which is part of production code
❷ The logger now implements that interface.
❸ The verifier now uses the interface.
请注意,生产代码中发生了一些变化。我已在生产代码中添加了一个新接口,现有记录器现在实现了该接口。我正在更改设计以使记录器可更换。此外,PasswordVerifier类与接口而不是SimpleLogger类一起工作。这允许我用假实例替换类的实例logger,而不是对真实记录器有硬依赖。
Notice that a few things have changed in the production code. I’ve added a new interface to the production code, and the existing logger now implements this interface. I’m changing the design to make the logger replaceable. Also, the PasswordVerifier class works with the interface instead of the SimpleLogger class. This allows me to replace the instance of the logger class with a fake one, instead of having a hard dependency on the real logger.
以下清单显示了强类型语言中的测试可能是什么样子,但使用了实现接口的手写假对象ILogger。
The following listing shows what a test might look like in a strongly typed language, but with a handwritten fake object that implements the ILogger interface.
Listing 4.14 Injecting a handwritten mock ILogger
类 FakeLogger 实现 ILogger {
书面:字符串;
信息(文本:字符串){
this.writing = 文本;
}
}
描述('带有接口的密码验证器',()=> {
test('验证,使用记录器,调用记录器', () => {
const mockLog = new FakeLogger();
const verifier = new PasswordVerifier ([], mockLog );
verifier.verify('任何东西');
期望( mockLog.writing ).toMatch(/PASS/);
});
});class FakeLogger implements ILogger {
written: string;
info(text: string) {
this.written = text;
}
}
describe('password verifier with interfaces', () => {
test('verify, with logger, calls logger', () => {
const mockLog = new FakeLogger();
const verifier = new PasswordVerifier([], mockLog);
verifier.verify('anything');
expect(mockLog.written).toMatch(/PASS/);
});
});
在此示例中,我创建了一个名为 的手写类FakeLogger。它所做的只是重写ILogger接口中的一个方法并保存text参数以供将来断言。然后我们将该值公开为类中的字段written。一旦暴露该值,我们就可以通过检查该字段来验证是否调用了假记录器。
In this example, I’ve created a handwritten class called FakeLogger. All it does is override the one method in the ILogger interface and save the text parameter for future assertion. We then expose this value as a field in the written class. Once this value is exposed, we can verify that the fake logger was called by checking that field.
我手动完成此操作是因为我希望您看到即使在面向对象的领域中,模式也会重复出现。我们现在拥有一个模拟对象,而不是一个模拟函数,但代码和测试的工作方式与前面的示例相同。
I’ve done this manually because I wanted you to see that even in object-oriented land, the patterns repeat themselves. Instead of having a mock function, we now have a mock object, but the code and test work just like the previous examples.
当接口比较复杂时,例如当其中包含超过一两个函数,或者每个函数中超过一两个参数时,会发生什么情况?
What happens when the interface is more complicated, such as when it has more than one or two functions in it, or more than one or two parameters in each function?
清单 4.15 是这种复杂接口的示例,也是使用复杂记录器(作为接口注入)的生产代码验证器的示例。该IComplicatedLogger接口有四个函数,每个函数都有一个或多个参数。每个函数都需要在我们的测试中伪造,这可能会导致我们的代码和测试中的复杂性和可维护性问题。
Listing 4.15 is an example of such a complicated interface, and of the production code verifier that uses the complicated logger, injected as an interface. The IComplicatedLogger interface has four functions, each with one or more parameters. Every function would need to be faked in our tests, and that can lead to complexity and maintainability problems in our code and tests.
Listing 4.15 Working with a more complicated interface (production code)
导出接口IComplicatedLogger { ❶
info (text: string)
debug (text: string, obj: any)
warn (text: string)
error (text: string, location: string, stacktrace: string)
}
导出类密码验证器2 {
私有_规则:任何[];
私人_logger:IComplicatedLogger; ❷
构造函数(规则:any[],记录器:IComplicatedLogger){ ❷
this._rules = 规则;
this._logger = 记录器;
}
...
}export interface IComplicatedLogger { ❶
info(text: string)
debug(text: string, obj: any)
warn(text: string)
error(text: string, location: string, stacktrace: string)
}
export class PasswordVerifier2 {
private _rules: any[];
private _logger: IComplicatedLogger; ❷
constructor(rules: any[], logger: IComplicatedLogger) { ❷
this._rules = rules;
this._logger = logger;
}
...
}
❶ A new interface, which is part of production code
❷ The class now works with the new interface.
正如您所看到的,新IComplicatedLogger界面将成为生产代码的一部分,这将使其logger可替换。我将省略真实记录器的实现,因为它与我们的示例无关。这就是使用接口抽象事物的好处:我们不需要直接引用它们。另请注意,类的构造函数中期望的参数类型是接口的类型IComplicatedLogger。这允许我用假的记录器类实例替换记录器类的实例,就像我们之前所做的那样。
As you can see, the new IComplicatedLogger interface will be part of production code, which will make the logger replaceable. I’m leaving off the implementation of a real logger, because it’s not relevant for our examples. That’s the benefit of abstracting away things with an interface: we don’t need to reference them directly. Also notice that the type of parameter expected in the class’s constructor is that of the IComplicatedLogger interface. This allows me to replace the instance of the logger class with a fake one, just like we did before.
这是测试的样子。它必须重写每个接口函数,这会创建又长又烦人的样板代码。
Here’s what the test looks like. It has to override each and every interface function, which creates long and annoying boilerplate code.
Listing 4.16 Test code with a complicated logger interface
描述(“使用长接口”,()=> {
描述(“密码验证器”,()=> {
类 FakeComplicatedLogger ❶
实现 IComplicatedLogger { ❶
infoWritten = "";
调试写入=“”;
错误写入=“”;
警告写入=“”;
调试(文本:字符串,对象:任意){
this.debugWritten = 文本;
}
错误(文本:字符串,位置:字符串,堆栈跟踪:字符串){
this.errorWritten = 文本;
}
信息(文本:字符串){
this.infoWritten = 文本;
}
警告(文本:字符串){
this.warnWritten = 文本;
}
}
...
test("验证通过,使用记录器,使用 PASS 调用记录器", () => {
const mockLog = new FakeComplicatedLogger();
constverifier=newPasswordVerifier2([],mockLog);
verifier.verify("任何东西");
期望(mockLog.infoWritten).toMatch(/通过/);
});
test("此测试的更面向 JS 的变体", () => {
const mockLog = {} 作为 IComplicatedLogger;
让记录=“”;
mockLog.info = (文本) => (记录 = 文本);
constverifier=newPasswordVerifier2([],mockLog);
verifier.verify("任何东西");
期望(记录)。toMatch(/通过/);
});
});
});describe("working with long interfaces", () => {
describe("password verifier", () => {
class FakeComplicatedLogger ❶
implements IComplicatedLogger { ❶
infoWritten = "";
debugWritten = "";
errorWritten = "";
warnWritten = "";
debug(text: string, obj: any) {
this.debugWritten = text;
}
error(text: string, location: string, stacktrace: string) {
this.errorWritten = text;
}
info(text: string) {
this.infoWritten = text;
}
warn(text: string) {
this.warnWritten = text;
}
}
...
test("verify passing, with logger, calls logger with PASS", () => {
const mockLog = new FakeComplicatedLogger();
const verifier = new PasswordVerifier2([], mockLog);
verifier.verify("anything");
expect(mockLog.infoWritten).toMatch(/PASSED/);
});
test("A more JS oriented variation on this test", () => {
const mockLog = {} as IComplicatedLogger;
let logged = "";
mockLog.info = (text) => (logged = text);
const verifier = new PasswordVerifier2([], mockLog);
verifier.verify("anything");
expect(logged).toMatch(/PASSED/);
});
});
});
❶ A fake logger class that implements the new interface
在这里,我们再次声明一个FakeComplicatedLogger实现该IComplicatedLogger接口的假记录器类 ( )。看看我们有多少样板代码。如果我们使用强类型的面向对象语言(例如 Java、C# 或 C++),这一点尤其正确。有很多方法可以解决所有这些样板代码,我们将在下一章中讨论。
Here, we’re declaring, again, a fake logger class (FakeComplicatedLogger) that implements the IComplicatedLogger interface. Look at how much boilerplate code we have. This will be especially true if we’re working in strongly typed object-oriented languages such as Java, C#, or C++. There are ways around all this boilerplate code, which we’ll touch on in the next chapter.
There are other downsides to using long, complicated interfaces in our tests:
If we’re saving arguments being sent in manually, it’s more cumbersome to verify multiple arguments across multiple methods and calls.
It’s likely that we are depending on third-party interfaces instead of internal ones, and this will end up making our tests more brittle as time goes by.
Even if we are depending on internal interfaces, long interfaces have more reasons to change, and now so do our tests.
这对我们意味着什么?我强烈建议仅使用满足这两个条件的假接口:
What does this mean for us? I highly recommend using only fake interfaces that meet both of these conditions:
You control the interfaces (they are not made by a third party).
They are adapted to the needs of your unit of work or component.
上述条件中的第二个可能需要一些解释。它与接口隔离原则有关(ISP;https://en.wikipedia.org/wiki/Interface_segregation_principle)。ISP 意味着,如果我们有一个接口包含比我们需要的更多的功能,我们应该创建一个小而简单的适配器接口,其中只包含我们需要的功能,最好具有更少的功能、更好的名称和更少的参数。
The second of the preceding conditions might need a bit of explanation. It relates to the interface segregation principle (ISP; https://en.wikipedia.org/wiki/Interface_segregation_principle). ISP means that if we have an interface that contains more functionality than we require, we should create a small, simpler adapter interface that contains just the functionality we need, preferably with fewer functions, better names, and fewer parameters.
这最终将使我们的测试变得更加简单。通过抽象出真正的依赖关系,当复杂的接口发生变化时,我们不需要改变我们的测试——只需要在某个地方改变一个适配器类文件。我们将在第 5 章中看到一个例子。
This will end up making our tests much simpler. By abstracting away the real dependencies, we won’t need to change our tests when the complicated interfaces change—only a single adapter class file somewhere. We’ll see an example of this in chapter 5.
在 JavaScript 和大多数其他语言以及相关的测试框架中,可以接管现有的对象和函数并“监视”它们。通过监视它们,我们可以稍后检查它们是否被调用、调用了多少次以及使用了哪些参数。
It’s possible, in JavaScript and in most other languages and associated test frameworks, to take over existing objects and functions and “spy” on them. By spying on them, we can later check if they were called, how many times, and with which arguments.
这本质上可以将真实对象的一部分转换为模拟函数,同时保持对象的其余部分作为真实对象。这可能会创建更复杂、更脆弱的测试,但有时它可能是一个可行的选择,特别是如果您正在处理遗留代码(有关遗留代码的更多信息,请参阅第 12 章)。
This essentially can turn parts of a real object into mock functions, while keeping the rest of the object as a real object. This can create more complicated tests that are more brittle, but it can sometimes be a viable option, especially if you’re dealing with legacy code (see chapter 12 for more on legacy code).
以下清单显示了此类测试的外观。我们创建真正的记录器,然后我们只需使用自定义函数覆盖其现有的实际函数之一。
The following listing shows what such a test might look like. We create the real logger, and then we simply override one of its existing real functions using a custom function.
Listing 4.17 A partial mock example
描述(“带有接口的密码验证器”,()=> {
test("验证,使用记录器,调用记录器", () => {
const testableLog: RealLogger = new RealLogger(); ❶
让记录=“”;
testableLog.info = (文本) => (记录 = 文本); ❷
const verifier = new PasswordVerifier([], testableLog);
verifier.verify("任意输入");
期望(记录)。toMatch(/通过/);
});
});describe("password verifier with interfaces", () => {
test("verify, with logger, calls logger", () => {
const testableLog: RealLogger = new RealLogger(); ❶
let logged = "";
testableLog.info = (text) => (logged = text); ❷
const verifier = new PasswordVerifier([], testableLog);
verifier.verify("any input");
expect(logged).toMatch(/PASSED/);
});
});
❷ Mocking one of its functions
在此测试中,我将实例化一个RealLogger,并在下一行中将其现有函数之一替换为假函数。更具体地说,我使用一个模拟函数,它允许我使用自定义变量跟踪其最新的调用参数。
In this test, I’m instantiating a RealLogger, and in the next line I’m replacing one of its existing functions with a fake one. More specifically, I’m using a mock function that allows me to track its latest invocation parameter using a custom variable.
这里重要的部分是该testableLog变量是部分模拟。这意味着至少它的一些内部实现不是假的,并且可能具有真正的依赖关系和逻辑。
The important part here is that the testableLog variable is a partial mock. That means that at least some of its internal implementation is not fake and might have real dependencies and logic in it.
有时使用部分模拟是有意义的,特别是当您使用遗留代码并且可能需要将某些现有代码与其依赖项隔离时。我将在第 12 章中详细讨论这一点。
Sometimes it makes sense to use partial mocks, especially when you’re working with legacy code and you might need to isolate some existing code from its dependencies. I’ll touch more on that in chapter 12.
部分模拟的一种面向对象版本使用继承来覆盖真实类中的函数,以便我们可以验证它们是否被调用。下面的清单显示了我们如何使用 JavaScript 中的继承和覆盖来做到这一点。
One object-oriented version of a partial mock uses inheritance to override functions from real classes so that we can verify they were called. The following listing shows how we can do this using inheritance and overrides in JavaScript.
Listing 4.18 An object-oriented partial mock example
类 TestableLogger 扩展了 RealLogger { ❶logging
= "";
信息(文本){ ❷
this.logged = 文本; ❷
} ❷
// error() 和 debug() 函数
// 仍然是“真实的”
}
描述(“带有继承的部分模拟”,()=> {
test("使用记录器验证,调用记录器", () => {
const mockLog: TestableLogger = new TestableLogger();
const verifier = new PasswordVerifier([], mockLog );
verifier.verify("任意输入");
期望( mockLog.logged ).toMatch(/PASSED/);
});
});class TestableLogger extends RealLogger { ❶
logged = "";
info(text) { ❷
this.logged = text; ❷
} ❷
// the error() and debug() functions
// are still "real"
}
describe("partial mock with inheritance", () => {
test("verify with logger, calls logger", () => {
const mockLog: TestableLogger = new TestableLogger();
const verifier = new PasswordVerifier([], mockLog);
verifier.verify("any input");
expect(mockLog.logged).toMatch(/PASSED/);
});
});
❶ Inheriting from the real logger
❷ Overriding one of its functions
我在测试中继承了真实的记录器类,然后在测试中使用继承的类,而不是原始类。这种技术通常称为提取和覆盖,您可以在 Michael Feathers 的著作《有效处理遗留代码》(Pearson,2004 年)中找到更多相关信息。
I inherit from the real logger class in my tests and then use the inherited class, not the original class, in my tests. This technique is commonly called Extract and Override, and you can find more about this in Michael Feathers’ book Working Effectively with Legacy Code (Pearson, 2004).
请注意,我将假记录器类命名为“TestableXXX”,因为它是真实生产代码的可测试版本,包含假代码和真实代码的混合,并且此约定帮助我向读者明确说明这一点。我还将课程与考试放在一起。我的生产代码不需要知道这个类的存在。这种提取和覆盖样式要求生产代码中的类允许继承并且该函数允许覆盖。在 JavaScript 中,这不是一个问题,但在 Java 和 C# 中,这些是需要做出的显式设计选择(尽管有一些框架允许我们规避此规则;我们将在下一章中讨论它们)。
Note that I’ve named the fake logger class “TestableXXX” because it’s a testable version of real production code, containing a mix of fake and real code, and this convention helps me make this explicit for the reader. I also put the class right alongside my tests. My production code doesn’t need to know that this class exists. This Extract and Override style requires that my class in production code allows inheritance and that the function allows overriding. In JavaScript this is less of an issue, but in Java and C# these are explicit design choices that need to be made (although there are frameworks that allow us to circumvent this rule; we’ll discuss them in the next chapter).
在这种情况下,我们继承了一个不直接测试的类 ( RealLogger)。我们使用该类来测试另一个类 ( PasswordVerifier)。然而,这种技术可以非常有效地用于从您直接测试的类中隔离和桩或模拟单个函数。当我们讨论遗留代码和重构技术时,我们将在本书后面详细讨论这一点。
In this scenario, we’re inheriting from a class that we’re not testing directly (RealLogger). We use that class to test another class (PasswordVerifier). However, this technique can be used quite effectively to isolate and stub or mock single functions from classes that you’re directly testing. We’ll touch more on that later in the book when we talk about legacy code and refactoring techniques.
交互测试是一种检查工作单元如何与其传出依赖项交互的方法:进行了哪些调用以及使用了哪些参数。交互测试涉及第三种类型的出口点:第三方模块、对象或系统。(前两种类型是返回值和状态改变。)
Interaction testing is a way to check how a unit of work interacts with its outgoing dependencies: what calls were made and with which parameters. Interaction testing relates to the third type of exit points: a third-party module, object, or system. (The first two types are a return value and a state change.)
要进行交互测试,您应该使用模拟,它们是替换传出依赖项的测试替身。桩替换传入的依赖项。您应该在测试中验证与模拟的交互,而不是与桩的交互。与模拟不同,与桩的交互是实现细节,不应检查。
To do interaction testing, you should use mocks, which are test doubles that replace outgoing dependencies. Stubs replace incoming dependencies. You should verify interactions with mocks in tests, but not with stubs. Unlike with mocks, interactions with stubs are implementation details and shouldn't be checked.
It’s OK to have multiple stubs in a test, but you don’t usually want to have more than a single mock per test, because that means you’re testing more than one requirement in a single test.
Just like with stubs, there are multiple ways to inject a mock into a unit of work:
在JavaScript中,可以部分实现复杂的接口,这有助于减少样板文件的数量。还可以选择使用部分模拟,您可以从真实的类继承并仅用假类替换其某些方法。
In JavaScript, a complicated interface can be implemented partially, which helps reduce the amount of boilerplate. There’s also the option of using partial mocks, where you inherit from a real class and replace only some of its methods with fakes.
在前面的章节中,我们研究了手动编写模拟和桩,并看到了所涉及的挑战,特别是当我们想要伪造的接口要求我们创建长的、容易出错的重复代码时。我们一直不得不声明自定义变量,创建自定义函数,或者从使用这些变量的类继承,并且基本上使事情变得比需要的更复杂(大多数时候)。
In the previous chapters, we looked at writing mocks and stubs manually and saw the challenges involved, especially when the interface we’d like to fake requires us to create long, error prone, repetitive code. We kept having to declare custom variables, create custom functions, or inherit from classes that use those variables and basically make things a bit more complicated than they need to be (most of the time).
在本章中,我们将以隔离框架的形式研究这些问题的一些优雅的解决方案——一个可以在运行时创建和配置假对象的可重用库。这些对象称为动态桩和动态模拟。
In this chapter, we’ll look at some elegant solutions to these problems in the form of an isolation framework—a reusable library that can create and configure fake objects at run time. These objects are referred to as dynamic stubs and dynamic mocks.
我将它们称为隔离框架,因为它们允许您将工作单元与其依赖项隔离。您会发现许多资源将它们称为“模拟框架”,但我尽量避免这种情况,因为它们可以用于模拟和桩。在本章中,我们将了解一些可用的 JavaScript 框架,以及如何在模块化、函数式和面向对象的设计中使用它们。您将看到如何使用此类框架来测试各种事物并创建桩、模拟和其他有趣的事物。
I call them isolation frameworks because they allow you to isolate the unit of work from its dependencies. You’ll find that many resources will refer to them as “mocking frameworks,” but I try to avoid that because they can be used for both mocks and stubs. In this chapter, we’ll take a look at a few of the JavaScript frameworks available and how we can use them in modular, functional, and object-oriented designs. You’ll see how you can use such frameworks to test various things and to create stubs, mocks, and other interesting things.
但我在这里介绍的具体框架不是重点。在使用它们时,您将看到它们的 API 在您的测试中所带来的价值(可读性、可维护性、健壮且持久的测试等等),并且您将发现隔离框架的优点是什么,或者是什么可能会成为您测试的一个缺点。
But the specific frameworks I’ll present here aren’t the point. While using them, you’ll see the values that their APIs promote in your tests (readability, maintainability, robust and long-lasting tests, and more), and you’ll find out what makes an isolation framework good and, alternatively, what can make it a drawback for your tests.
我将从一个听起来有点平淡的基本定义开始,但它需要通用才能包含各种隔离框架:
I’ll start with a basic definition that may sound a bit bland, but it needs to be generic in order to include the various isolation frameworks out there:
隔离框架是一组可编程 API,允许以对象或函数形式动态创建、配置和验证模拟和桩。使用隔离框架时,这些任务通常比手工编码的模拟和桩更简单、更快,并且生成的代码更短。
An isolation framework is a set of programmable APIs that allow the dynamic creation, configuration, and verification of mocks and stubs, either in object or function form. When using an isolation framework, these tasks are often simpler, quicker, and produce shorter code than hand-coded mocks and stubs.
如果使用得当,隔离框架可以使开发人员免于编写重复的代码来断言或模拟对象交互,如果应用在正确的地方,它们可以帮助使测试持续多年,而不需要开发人员回来修复它们在每次小的生产代码更改之后。如果它们应用不当,可能会导致混乱和对这些框架的全面滥用,以至于我们无法阅读或无法信任我们自己的测试,所以要小心。我将在本书的第三部分中讨论一些注意事项。
Isolation frameworks, when used properly, can save developers from the need to write repetitive code to assert or simulate object interactions, and if applied in the right places, they can help make tests last many years without requiring a developer to come back and fix them after every little production code change. If they’re applied badly, they can cause confusion and full-on abuse of these frameworks, to the point where we either can’t read or can’t trust our own tests, so be wary. I’ll discuss some dos and don’ts in part 3 of this book.
由于 JavaScript 支持多种编程设计范式,因此我们可以将世界中的框架分为两种主要类型:
Because JavaScript supports multiple paradigms of programming design, we can split the frameworks in our world into two main flavors:
松散 JavaScript 隔离框架——这些是原生 JavaScript 友好的松散类型隔离框架(例如 Jest 和 Sinon)。这些框架通常也更适合更实用的代码风格,因为它们需要更少的仪式和样板代码来完成工作。
Loose JavaScript isolation frameworks—These are vanilla JavaScript-friendly loose-typed isolation frameworks (such as Jest and Sinon). These frameworks usually also lend themselves better to more functional styles of code because they require less ceremony and boilerplate code to do their work.
类型化 JavaScript 隔离框架——这些是更加面向对象且对 TypeScript 友好的隔离框架(例如替代.js)。它们在处理整个类和接口时非常有用。
Typed JavaScript isolation frameworks—These are more object-oriented and TypeScript-friendly isolation frameworks (such as substitute.js). They’re very useful when dealing with whole classes and interfaces.
您最终选择在项目中使用哪种风格取决于一些因素,例如品味、风格和可读性,但首先的主要问题是,您最需要伪造什么类型的依赖项?
Which flavor you end up choosing to use in your project will depend on a few things, like taste, style, and readability, but the main question to start with is, what type of dependencies will you mostly need to fake?
Module dependencies (imports, requires)—Jest and other loosely typed frameworks should work well.
Functional (single and higher-order functions, simple parameters and values)—Jest and other loosely typed frameworks should work well.
Full objects, object hierarchies, and interfaces—Look into the more object-oriented frameworks, such as substitute.js.
让我们回到密码验证器,看看如何伪造与前几章中相同类型的依赖项,但这次使用框架。
Let’s go back to our Password Verifier and see how we can fake the same types of dependencies we did in previous chapters, but this time using a framework.
对于那些尝试使用require或来测试直接依赖于模块的代码的人import来说,Jest 或 Sinon 等隔离框架提供了用很少的代码动态伪造整个模块的强大能力。由于我们一开始使用 Jest 作为测试框架,因此我们将在本章的示例中继续使用它。
For people who are trying to test code with direct dependencies on modules using require or import, isolation frameworks such as Jest or Sinon present the powerful ability to fake an entire module dynamically, with very little code. Since we started with Jest as our test framework, we’ll stick with it for the examples in this chapter.
Figure 5.1 illustrates a Password Verifier with two dependencies:
A configuration service that helps decide what the logging level is (INFO or ERROR)
A logging service that we call as the exit point of our unit of work, whenever we verify a password
图 5.1 密码验证器有两个依赖项:传入的依赖项用于确定日志记录级别,传出的依赖项用于创建日志条目。
Figure 5.1 Password Verifier has two dependencies: an incoming one to determine the logging level, and an outgoing one to create a log entry.
箭头表示通过工作单元的行为流程。思考箭头的另一种方法是通过术语command和query。我们正在查询配置服务(以获取日志级别),但我们正在向记录器发送命令(以记录日志)。
The arrows represent the flow of behavior through the unit of work. Another way to think about the arrows is through the terms command and query. We are querying the configuration service (to get the log level), but we are sending commands to the logger (to log).
The following listing shows a Password Verifier that has a hard dependency on a logger module.
Listing 5.1 Code with hardcoded modular dependencies
const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");
常量日志=(文本)=> {
如果(getLogLevel()===“信息”){
信息(文本);
}
if (getLogLevel() === "调试") {
调试(文本);
}
};
const verifyPassword = (输入, 规则) => {
const 失败 = 规则
.map((规则) => 规则(输入))
.filter((结果) => 结果 === false);
if (失败.length === 0) {
日志(“通过”);
返回真;
}
日志(“失败”);
返回假;
};const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");
const log = (text) => {
if (getLogLevel() === "info") {
info(text);
}
if (getLogLevel() === "debug") {
debug(text);
}
};
const verifyPassword = (input, rules) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
log("PASSED");
return true;
}
log("FAIL");
return false;
};
In this example we’re forced to find a way to do two things:
Simulate (stub) values returned from the configuration service’s getLogLevel function.
Verify (mock) that the logger module’s info function was called.
Figure 5.2 shows a visual representation of this.
图 5.2 测试桩传入依赖项(配置服务)并模拟传出依赖项(记录器)。
Figure 5.2 The test stubs an incoming dependency (the configuration service) and mocks the outgoing dependency (the logger).
Jest 向我们提供了几种完成模拟和验证的方法,其中一种更简洁的方法是jest.mock([module name])在规范文件的顶部使用,然后我们在测试中需要假模块,以便我们可以配置它们。
Jest presents us with a few ways to accomplish both simulation and verification, and one of the cleaner ways it presents is using jest.mock([module name]) at the top of the spec file, followed by us requiring the fake modules in our tests so that we can configure them.
Listing 5.2 Faking the module APIs directly with jest.mock()
jest.mock("./complicated-logger"); ❶
jest.mock("./configuration-service"); ❶
const { 字符串匹配 } = 期望;
const { verifyPassword } = require("./password-verifier");
const mockLoggerModule = require("./complicated-logger"); ❷
const stubConfigModule = require("./configuration-service"); ❷
描述(“密码验证器”,()=> {
afterEach(jest.resetAllMocks); ❸
test('有信息日志级别且没有规则,
它使用 PASSED', () => { 调用记录器
StubConfigModule .getLogLevel .mockReturnValue("info"); ❹
验证密码(“任何内容”,[]);
期望(mockLoggerModule.info ) ❺
.toHaveBeenCalledWith(stringMatching( / PASS/)); ❺
});
test('具有调试日志级别且无规则,
它使用 PASSED', () => { 调用记录器
StubConfigModule .getLogLevel .mockReturnValue("调试"); ❻
验证密码(“任何内容”,[]);
期望(mockLoggerModule.debug ) ❼
.toHaveBeenCalledWith(stringMatching( / PASS/)); ❼
});
});jest.mock("./complicated-logger"); ❶
jest.mock("./configuration-service"); ❶
const { stringMatching } = expect;
const { verifyPassword } = require("./password-verifier");
const mockLoggerModule = require("./complicated-logger"); ❷
const stubConfigModule = require("./configuration-service"); ❷
describe("password verifier", () => {
afterEach(jest.resetAllMocks); ❸
test('with info log level and no rules,
it calls the logger with PASSED', () => {
stubConfigModule.getLogLevel.mockReturnValue("info"); ❹
verifyPassword("anything", []);
expect(mockLoggerModule.info) ❺
.toHaveBeenCalledWith(stringMatching(/PASS/)); ❺
});
test('with debug log level and no rules,
it calls the logger with PASSED', () => {
stubConfigModule.getLogLevel.mockReturnValue("debug"); ❻
verifyPassword("anything", []);
expect(mockLoggerModule.debug) ❼
.toHaveBeenCalledWith(stringMatching(/PASS/)); ❼
});
});
❷ Getting the fake instances of the modules
❸ Telling Jest to reset any fake module behavior between tests
❹ Configuring the stub to return a fake “info” value.
❺ Asserting that the mock was called correctly
❼ Asserting on the mock logger as done previously
通过在这里使用 Jest,我节省了大量的打字时间,而且测试看起来仍然非常可读。
By using Jest here, I’ve saved myself a bunch of typing, and the tests still look pretty readable.
Jest 几乎在所有地方都使用“mock”这个词,无论我们是在桩还是嘲笑它们,这可能会有点令人困惑。如果它有“桩”一词别名为“模拟”以使内容更具可读性,那就太好了。
Jest uses the word “mock” almost everywhere, whether we’re stubbing things or mocking them, which can be a bit confusing. It’d be great if it had the word “stub” aliased to “mock” to make things more readable.
另外,由于 JavaScript“提升”的工作方式,伪造模块的行(通过jest.mock)需要位于文件的顶部。您可以在 Ashutosh Verma 的“理解 JavaScript 中的提升”文章中阅读更多相关信息:http://mng.bz/j11r。
Also, due to the way JavaScript “hoisting” works, the lines faking the modules (via jest.mock) will need to be at the top of the file. You can read more about this in Ashutosh Verma’s “Understanding Hoisting in JavaScript” article here: http://mng.bz/j11r.
另请注意,Jest 还有许多其他 API 和功能,如果您有兴趣使用它,那么值得探索它们。请访问https://jestjs.io/以了解完整情况 - 这超出了本书的范围,本书主要是关于模式,而不是工具。
Also note that Jest has many other APIs and abilities, and its worth exploring them if you’re interested in using it. Head over to https://jestjs.io/ to get the full picture—it’s beyond the scope of this book, which is mostly about patterns, not tools.
其他一些框架,其中包括Sinon(https://sinonjs.org),也支持伪造模块。就隔离框架而言,Sinon 的使用非常愉快,但与 JavaScript 世界中的许多其他框架一样,也很像 Jest,它包含太多完成同一任务的方法,这通常会令人困惑。尽管如此,如果没有这些框架,手动伪造模块可能会非常烦人。
A few other frameworks, among them Sinon (https://sinonjs.org), also support faking modules. Sinon is quite pleasant to work with, as far as isolation frameworks go, but like many other frameworks in the JavaScript world, and much like Jest, it contains too many ways of accomplishing the same task, and that can often be confusing. Still, faking modules by hand can be quite annoying without these frameworks.
关于 API 和其他类似 API 的好消息jest.mock是,它满足了开发人员的真实需求,这些开发人员一直在尝试测试具有不易更改的内置依赖项(即他们无法控制的代码)的模块。这个问题在遗留代码情况下非常普遍,我将在第 12 章中讨论。
The good news about the jest.mock API, and others like it, is that it meets a very real need for developers who are stuck trying to test modules that have baked-in dependencies that are not easily changeable (i.e., code they cannot control). This issue is very prevalent in legacy code situations, which I’ll discuss in chapter 12.
关于 API 的坏消息jest.mock是,它还允许我们模拟我们所控制的代码,并且这可能会从抽象出更简单、更短的内部 API 背后的真正依赖关系中受益。这种方法也称为洋葱架构或六边形架构或端口和适配器,对于我们代码的长期可维护性非常有用。您可以在 Alistair Cockburn 的文章“六角形架构”中阅读有关此类架构的更多信息,网址为https://alistair.cockburn.us/hexagonal-architecture/。
The bad news about the jest.mock API is that it also allows us to mock the code that we do control and that might have benefited from abstracting away the real dependencies behind simpler, shorter, internal APIs. This approach, also known as onion architecture or hexagonal architecture or ports and adapters, is very useful for the long-term maintainability of our code. You can read more about this type of architecture in Alistair Cockburn’s article, “Hexagonal Architecture,” at https://alistair.cockburn.us/hexagonal-architecture/.
为什么直接依赖可能存在问题?通过直接使用这些 API,我们还被迫在测试中直接伪造模块 API,而不是它们的抽象。我们将这些直接 API 的设计与测试的实现结合起来,这意味着如果(或实际上当)这些 API 发生变化,我们还需要更改许多测试。
Why are direct dependencies potentially problematic? By using those APIs directly, we’re also forced into faking the module APIs directly in our tests instead of their abstractions. We’re gluing the design of those direct APIs to the implementation of the tests, which means that if (or really, when) those APIs change, we’ll also need to change many of our tests.
这是一个简单的例子。想象一下,您的代码依赖于一个著名的 JavaScript 日志框架(例如 Winston),并且在代码中的数百或数千个位置直接依赖于它。然后想象一下温斯顿发布了一个重大升级。随之而来的是很多痛苦,这些痛苦本可以在事情失控之前更早地解决。实现此目的的一种简单方法是对单个适配器文件进行简单抽象,该适配器文件是唯一保存对该记录器的引用的文件。该抽象可以公开我们可以控制的更简单的内部日志记录 API,因此我们可以防止代码出现大规模破坏。我将在第 12 章再次讨论这个主题。
Here’s a quick example. Imagine your code depends on a well-known JavaScript logging framework (such as Winston) and depends on it directly in hundreds or thousands of places in the code. Then imagine that Winston releases a breaking upgrade. Lots of pain will ensue, which could have been addressed much earlier, before things got out of hand. One simple way to accomplish this would be with a simple abstraction to a single adapter file, which is the only one holding a reference to that logger. That abstraction can expose a simpler, internal logging API that we do control, so we can prevent large-scale breakage across our code. I’ll return to this subject in chapter 12.
我们讨论了模块化依赖关系,所以让我们转向伪造简单的函数。我们在前面的章节中已经做过很多次了,但我们总是手动完成。这对于桩来说非常有效,但是对于模拟来说它很快就会变得烦人。
We covered modular dependencies, so let’s turn to faking simple functions. We’ve done that plenty of times in the previous chapters, but we’ve always done it by hand. That works great for stubs, but for mocks it gets annoying fast.
The following listing shows the manual approach we used before.
Listing 5.3 Manually mocking a function to verify it was called
test("给定记录器和传递场景", () => {
letlogging = ""; ❶
const mockLog = { info: (text) => (logged = text) }; ❷
const passVerify = makeVerifier([],mockLog);
passVerify("任何输入");
期望(记录)。toMatch(/通过/); ❸
});test("given logger and passing scenario", () => {
let logged = ""; ❶
const mockLog = { info: (text) => (logged = text) }; ❷
const passVerify = makeVerifier([], mockLog);
passVerify("any input");
expect(logged).toMatch(/PASSED/); ❸
});
❶ Declaring a custom variable to hold the value passed in
❷ Saving the passed-in value to that variable
❸ Asserting on the value of the variable
它有效——我们能够验证记录器函数是否被调用,但这需要大量的工作,并且可能会变得非常重复。输入像 Jest 这样的隔离框架。jest.fn()是摆脱此类代码的最简单方法。下面的清单显示了我们如何使用它。
It works—we’re able to verify that the logger function was called, but that’s a lot of work that can become very repetitive. Enter isolation frameworks like Jest. jest.fn() is the simplest way to get rid of such code. The following listing shows how we can use it.
Listing 5.4 Using jest.fn() for simple function mocks
test('给定记录器和传递场景', () => {
const mockLog = { 信息: jest.fn() };
const verify = makeVerifier([],mockLog);
验证('任何输入');
期望( mockLog.info )
.toHaveBeenCalledWith(stringMatching (/PASS/) ) ;
});test('given logger and passing scenario', () => {
const mockLog = { info: jest.fn() };
const verify = makeVerifier([], mockLog);
verify('any input');
expect(mockLog.info)
.toHaveBeenCalledWith(stringMatching(/PASS/));
});
将此代码与前面的示例进行比较。虽然很微妙,但可以节省大量时间。这里我们用来jest.fn()获取 Jest 自动跟踪的函数,以便我们稍后可以通过toHaveBeenCalledWith(). 它小巧可爱,每当您需要跟踪对特定函数的调用时,它都能很好地工作。该函数是匹配器stringMatching的一个示例。匹配器通常被定义为一个实用函数,可以对发送到函数中的参数值进行断言。Jest 文档更自由地使用该术语,但您可以在 Jest 文档中找到匹配器的完整列表,网址为https://jestjs.io/docs/en/expect。
Compare this code with the previous example. It’s subtle, but it saves plenty of time. Here we’re using jest.fn() to get back a function that is automatically tracked by Jest, so that we can query it later using Jest’s API via toHaveBeenCalledWith(). It’s small and cute, and it works well any time you need to track calls to a specific function. The stringMatching function is an example of a matcher. A matcher is usually defined as a utility function that can assert on the value of a parameter being sent into a function. The Jest docs use the term a bit more liberally, but you can find the full list of matchers in the Jest documentation at https://jestjs.io/docs/en/expect.
总而言之,jest.fn()它非常适合基于单函数的模拟和桩。让我们继续面对更加面向对象的挑战。
To summarize, jest.fn() works well for single-function-based mocks and stubs. Let’s move on to a more object-oriented challenge.
正如我们刚刚看到的,jest.fn()这是一个单函数伪造效用函数的示例。它在函数世界中运行良好,但当我们尝试在成熟的 API 接口或包含多个函数的类上使用它时,它会有点崩溃。
As we’ve just seen, jest.fn() is an example of a single-function faking utility function. It works well in a functional world, but it breaks down a bit when we try to use it on full-blown API interfaces or classes that contain multiple functions.
我之前提到过,隔离框架有两类。首先,我们将使用第一种(松散类型,功能友好)。下面的清单是一个尝试解决IComplicatedLogger我们在上一章中看到的问题的示例。
I mentioned before that there are two categories of isolation frameworks. To start, we’ll use the first (loosely typed, function-friendly) kind. The following listing is an example of trying to tackle the IComplicatedLogger we looked at in the previous chapter.
Listing 5.5 The IComplicatedLogger interface
导出接口IComplicatedLogger {
信息(文本:字符串,方法:字符串)
调试(文本:字符串,方法:字符串)
警告(文本:字符串,方法:字符串)
错误(文本:字符串,方法:字符串)
}export interface IComplicatedLogger {
info(text: string, method: string)
debug(text: string, method: string)
warn(text: string, method: string)
error(text: string, method: string)
}
为此接口创建手写桩或模拟可能非常耗时,因为您需要记住每个方法的参数,如下一个清单所示。
Creating a handwritten stub or mock for this interface may be very time consuming, because you’d need to remember the parameters on a per-method basis, as the next listing shows.
Listing 5.6 Handwritten stubs creating lots of boilerplate code
描述(“使用长接口”,()=> {
描述(“密码验证器”,()=> {
类 FakeLogger 实现 IComplicatedLogger {
调试文本=“”;
调试方法=“”;
错误文本=“”;
错误方法 = "";
信息文本=“”;
信息方法 = "";
警告文本=“”;
警告方法 = "";
调试(文本:字符串,方法:字符串){
this.debugText = 文本;
this.debugMethod = 方法;
}
错误(文本:字符串,方法:字符串){
this.errorText = 文本;
this.errorMethod = 方法;
}
...
}
test("验证,w 记录器并通过,使用 PASS 调用记录器", () => {
const mockLog = new FakeLogger();
constverifier=newPasswordVerifier2([],mockLog);
verifier.verify("任何东西");
期望(mockLog.infoText).toMatch(/通过/);
});
});
});describe("working with long interfaces", () => {
describe("password verifier", () => {
class FakeLogger implements IComplicatedLogger {
debugText = "";
debugMethod = "";
errorText = "";
errorMethod = "";
infoText = "";
infoMethod = "";
warnText = "";
warnMethod = "";
debug(text: string, method: string) {
this.debugText = text;
this.debugMethod = method;
}
error(text: string, method: string) {
this.errorText = text;
this.errorMethod = method;
}
...
}
test("verify, w logger & passing, calls logger with PASS", () => {
const mockLog = new FakeLogger();
const verifier = new PasswordVerifier2([], mockLog);
verifier.verify("anything");
expect(mockLog.infoText).toMatch(/PASSED/);
});
});
});
真是一团糟。这种手写的假数据不仅耗时且编写起来很麻烦,如果您希望它在测试中的某个位置返回特定值,或者模拟记录器上函数调用的错误,会发生什么情况?我们可以做到,但是代码很快就会变得丑陋。
What a mess. Not only is this handwritten fake time consuming and cumbersome to write, what happens if you want it to return a specific value somewhere in the test, or simulate an error from a function call on the logger? We can do it, but the code gets ugly fast.
使用隔离框架,执行此操作的代码变得琐碎、更具可读性并且更短。让我们用于jest.fn()相同的任务,看看我们最终会得到什么结果。
Using an isolation framework, the code for doing this becomes trivial, more readable, and much shorter. Let’s use jest.fn() for the same task and see where we end up.
Listing 5.7 Mocking individual interface functions with jest.fn()
导入 stringMatching = jasmine.stringMatching;
描述(“使用长接口”,()=> {
描述(“密码验证器”,()=> {
test("验证,w 记录器并通过,使用 PASS 调用记录器", () => {
const mockLog: IComplicatedLogger = { ❶
信息:jest.fn(), ❶
警告:jest.fn(), ❶
调试:jest.fn(), ❶
错误:jest.fn(), ❶
};
constverifier=newPasswordVerifier2([],mockLog);
verifier.verify("任何东西");
期望(mockLog.info)
.toHaveBeenCalledWith(stringMatching(/PASS/));
});
});
});import stringMatching = jasmine.stringMatching;
describe("working with long interfaces", () => {
describe("password verifier", () => {
test("verify, w logger & passing, calls logger with PASS", () => {
const mockLog: IComplicatedLogger = { ❶
info: jest.fn(), ❶
warn: jest.fn(), ❶
debug: jest.fn(), ❶
error: jest.fn(), ❶
};
const verifier = new PasswordVerifier2([], mockLog);
verifier.verify("anything");
expect(mockLog.info)
.toHaveBeenCalledWith(stringMatching(/PASS/));
});
});
});
❶ Setting up the mock using Jest
不是太寒酸。这里我们简单地概述了我们自己的对象,并将一个jest.fn()函数附加到界面中的每个函数上。这可以节省大量的输入,但有一个重要的警告:每当接口发生变化(例如添加一个函数)时,我们就必须返回到定义该对象的代码并添加该函数。对于普通 JavaScript,这不会是一个问题,但如果测试中的代码使用了我们在测试中未定义的函数,它仍然会造成一些复杂性。
Not too shabby. Here we simply outline our own object and attach a jest.fn() function to each of the functions in the interface. This saves a lot of typing, but it has one important caveat: whenever the interface changes (a function is added, for example), we’ll have to go back to the code that defines this object and add that function. With plain JavaScript, this would be less of an issue, but it can still create some complications if the code under test uses a function we didn’t define in the test.
无论如何,将此类伪对象的创建推送到工厂辅助方法中可能是明智的做法,以便创建仅存在于单个位置。
In any case, it might be wise to push the creation of such a fake object into a factory helper method, so that the creation only exists in a single place.
我们切换到第二类框架并尝试substitute.js(www.npmjs.com/package/@fluffy-spoon/substitute)。我们必须选择一个,我非常喜欢这个框架的 C# 版本,并在本书的前一版本中使用了它。
Let’s switch to the second category of frameworks and try substitute.js (www.npmjs.com/package/@fluffy-spoon/substitute). We have to choose one, and I like the C# version of this framework a lot and used it in the previous edition of this book.
使用 Replace.js(以及使用 TypeScript 的假设),我们可以编写如下代码。
With substitute.js (and the assumption of working with TypeScript), we can write code like the following.
清单 5.8 使用 Replacement.js 来伪造一个完整的接口
Listing 5.8 Using substitute.js to fake a full interface
从“@fluffy-spoon/substitute”导入{ Substitute,Arg } ;
描述(“使用长接口”,()=> {
描述(“密码验证器”,()=> {
test("验证,w 记录器并通过,调用记录器 w PASS", () => {
const mockLog = Substitute.for<IComplicatedLogger>(); ❶
constverifier=newPasswordVerifier2([],mockLog);
verifier.verify("任何东西");
mockLog.received().info( ❷
Arg.is((x) => x.includes("PASSED")), ❷
"验证" ❷
);
});
});
});import { Substitute, Arg } from "@fluffy-spoon/substitute";
describe("working with long interfaces", () => {
describe("password verifier", () => {
test("verify, w logger & passing, calls logger w PASS", () => {
const mockLog = Substitute.for<IComplicatedLogger>(); ❶
const verifier = new PasswordVerifier2([], mockLog);
verifier.verify("anything");
mockLog.received().info( ❷
Arg.is((x) => x.includes("PASSED")), ❷
"verify" ❷
);
});
});
});
❷ Verifying the fake object was called
在前面的清单中,我们生成了假对象,这使我们无需关心我们正在测试的函数之外的任何函数,即使该对象的签名将来发生变化。然后,我们使用.received()另一个参数匹配器 来作为我们的验证机制,Arg.is这一次来自 replacement.js 的 API,它的工作方式就像 Jasmine 中的字符串匹配一样。这里的额外好处是,如果将新函数添加到对象的签名中,我们将不太可能需要更改测试,并且无需将这些函数添加到使用相同对象签名的任何测试中。
In the preceding listing, we generate the fake object, which absolves us of caring about any functions other than the one we’re testing against, even if the object’s signature changes in the future. We then use .received() as our verification mechanism, as well as another argument matcher, Arg.is, this time from substitute.js’s API, which works just like string matches from Jasmine. The added benefit here is that if new functions are added to the object’s signature, we will be less likely to need to change the test, and there’s no need to add those functions to any tests that use the same object signature.
OK, that was mocks. What about stubs?
Jest 有一个非常简单的 API,用于模拟模块化和功能依赖项的返回值:mockReturnValue()和mockReturnValueOnce()。
Jest has a very simple API for simulating return values for modular and functional dependencies: mockReturnValue() and mockReturnValueOnce().
清单 5.9 使用以下命令从伪函数中桩一个值jest.fn()
Listing 5.9 Stubbing a value from a fake function with jest.fn()
test("假相同的返回值", () => {
const StubFunc = jest.fn()
.mockReturnValue("abc");
//值保持不变
期望(stubFunc())。toBe(“abc”);
期望(stubFunc())。toBe(“abc”);
期望(stubFunc())。toBe(“abc”);
});
test("假多个返回值", () => {
const StubFunc = jest.fn()
.mockReturnValueOnce("a")
.mockReturnValueOnce("b")
.mockReturnValueOnce("c");
//值保持不变
期望(stubFunc())。toBe(“a”);
期望(stubFunc())。toBe(“b”);
期望(stubFunc())。toBe(“c”);
期望(stubFunc())。toBe(未定义);
});test("fake same return values", () => {
const stubFunc = jest.fn()
.mockReturnValue("abc");
//value remains the same
expect(stubFunc()).toBe("abc");
expect(stubFunc()).toBe("abc");
expect(stubFunc()).toBe("abc");
});
test("fake multiple return values", () => {
const stubFunc = jest.fn()
.mockReturnValueOnce("a")
.mockReturnValueOnce("b")
.mockReturnValueOnce("c");
//value remains the same
expect(stubFunc()).toBe("a");
expect(stubFunc()).toBe("b");
expect(stubFunc()).toBe("c");
expect(stubFunc()).toBe(undefined);
});
请注意,在第一个测试中,我们在测试期间设置了一个永久返回值。如果可以使用的话,这是我编写测试的首选方法,因为它使测试易于阅读和维护。如果我们确实需要模拟多个值,我们可以使用mockReturnValueOnce.
Notice that, in the first test, we’re setting a permanent return value for the duration of the test. This is my preferred method of writing tests if I can use it, because it makes the tests simple to read and maintain. If we do need to simulate multiple values, we can use mockReturnValueOnce.
如果您需要模拟错误或做任何更复杂的事情,您可以使用mockImplementation()and mockImplementationOnce():
If you need to simulate an error or do anything more complicated, you can use mockImplementation() and mockImplementationOnce():
你的桩。模拟实现(() => {
抛出新的错误();
});yourStub.mockImplementation(() => {
throw new Error();
});
Let’s add another ingredient into our Password Verifier equation.
Let’s say that the Password Verifier is not active during a special maintenance window, when software is being updated.
When a maintenance window is active, calling verify() on the verifier will cause it to call logger.info() with “under maintenance.”
Otherwise it will call logger.info() with a “passed” or “failed” result.
为此(并且为了展示面向对象的设计决策),我们将引入一个MaintenanceWindow接口,该接口将被注入到密码验证器的构造函数中,如图 5.3 所示。
For this purpose (and for the purpose of showing an object-oriented design decision), we’ll introduce a MaintenanceWindow interface that will be injected into the constructor of our Password Verifier, as illustrated in figure 5.3.
Figure 5.3 Using the MaintenanceWindow interface
The following listing shows the code for the Password Verifier using the new dependency.
清单 5.10 带有MaintenanceWindow依赖项的密码验证器
Listing 5.10 Password Verifier with a MaintenanceWindow dependency
导出类PasswordVerifier3 {
私有_规则:任何[];
私人_logger:IComplicatedLogger;
私有_maintenanceWindow:维护窗口;
构造函数(
规则:任意[],
记录器:IComplicatedLogger,
维护窗口:维护窗口
){
this._rules = 规则;
this._logger = 记录器;
这。_维护窗口 = 维护窗口;
}
验证(输入:字符串):布尔值{
if (this._maintenanceWindow.isUnderMaintenance ( )) {
this. _ logger.info("维护中", "验证");
返回假;
}
const 失败 = this._rules
.map((规则) => 规则(输入))
.filter((结果) => 结果 === false);
if (失败.length === 0) {
this._logger.info("通过", "验证");
返回真;
}
this._logger.info("失败", "验证");
返回假;
}
}export class PasswordVerifier3 {
private _rules: any[];
private _logger: IComplicatedLogger;
private _maintenanceWindow: MaintenanceWindow;
constructor(
rules: any[],
logger: IComplicatedLogger,
maintenanceWindow: MaintenanceWindow
) {
this._rules = rules;
this._logger = logger;
this._maintenanceWindow = maintenanceWindow;
}
verify(input: string): boolean {
if (this._maintenanceWindow.isUnderMaintenance()) {
this._logger.info("Under Maintenance", "verify");
return false;
}
const failed = this._rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
this._logger.info("PASSED", "verify");
return true;
}
this._logger.info("FAIL", "verify");
return false;
}
}
该MaintenanceWindow接口作为构造函数参数注入(即使用构造函数注入),用于确定在哪里执行或不执行密码验证并向记录器发送正确的消息。
The MaintenanceWindow interface is injected as a constructor parameter (i.e., using constructor injection), and it’s used to determine where to execute or not execute the password verification and send the proper message to the logger.
现在我们将使用 Replace.js 而不是 Jest 来创建MaintenanceWindow接口的桩和接口的模拟IComplicatedLogger。图 5.4 说明了这一点。
Now we’ll use substitute.js instead of Jest to create a stub of the MaintenanceWindow interface and a mock of the IComplicatedLogger interface. Figure 5.4 illustrates this.
Figure 5.4 A MaintenanceWindow dependency
使用 Replace.js 创建桩和模拟的工作方式相同:我们使用该Substitute.for<T>函数。我们可以使用该.returns函数配置桩并使用该函数验证.received模拟。这两个都是从返回的假对象的一部分Substitute.for<T>().
Creating stubs and mocks with substitute.js works the same way: we use the Substitute.for<T> function. We can configure stubs with the .returns function and verify mocks with the .received function. Both of these are part of the fake object that is returned from Substitute.for<T>().
Here’s what stub creation and configuration looks like:
const StubMaintWindow = Substitute.for<MaintenanceWindow> (); StubMaintWindow.isUnderMaintenance() .returns(true);
const stubMaintWindow = Substitute.for<MaintenanceWindow>(); stubMaintWindow.isUnderMaintenance().returns(true);
Mock creation and verification looks like this:
const mockLog = Substitute.for<IComplicatedLogger>();
。。。
/// 稍后在测试结束时...
mockLog .received() .info("维护中", "验证");const mockLog = Substitute.for<IComplicatedLogger>();
. . .
/// later down in the end of the test...
mockLog.received().info("Under Maintenance", "verify");
The following listing shows the full code for a couple of tests that use a mock and a stub.
Listing 5.11 Testing Password Verifier with substitute.js
从“@fluffy-spoon/substitute”导入{替代};
const makeVerifierWithNoRules = (log, maint) =>
新的PasswordVerifier3([], log, maint);
描述(“使用替代部分 2”,()=> {
test("验证,在维护期间,调用记录器", () => {
const StubMaintWindow = Substitute.for<MaintenanceWindow>() ;
StubMaintWindow.isUnderMaintenance() .returns (true);
const mockLog = Substitute.for<IComplicatedLogger>() ;
const verifier = makeVerifierWithNoRules(mockLog,stubMaintWindow);
verifier.verify("任何东西");
mockLog .received() .info("维护中", "验证");
});
test("验证,外部维护,调用记录器", () => {
const StubMaintWindow = Substitute.for<MaintenanceWindow>();
StubMaintWindow.isUnderMaintenance().returns(false);
const mockLog = Substitute.for<IComplicatedLogger>();
const verifier = makeVerifierWithNoRules(mockLog,stubMaintWindow);
verifier.verify("任何东西");
mockLog.received().info("通过", "验证");
});
});import { Substitute } from "@fluffy-spoon/substitute";
const makeVerifierWithNoRules = (log, maint) =>
new PasswordVerifier3([], log, maint);
describe("working with substitute part 2", () => {
test("verify, during maintanance, calls logger", () => {
const stubMaintWindow = Substitute.for<MaintenanceWindow>();
stubMaintWindow.isUnderMaintenance().returns(true);
const mockLog = Substitute.for<IComplicatedLogger>();
const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);
verifier.verify("anything");
mockLog.received().info("Under Maintenance", "verify");
});
test("verify, outside maintanance, calls logger", () => {
const stubMaintWindow = Substitute.for<MaintenanceWindow>();
stubMaintWindow.isUnderMaintenance().returns(false);
const mockLog = Substitute.for<IComplicatedLogger>();
const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);
verifier.verify("anything");
mockLog.received().info("PASSED", "verify");
});
});
我们可以使用动态创建的对象成功且相对容易地模拟测试中的值。我鼓励您研究您想要使用的隔离框架的风格。我在本书中只使用了 replacement.js 作为示例。它不是唯一的框架。
We can successfully and relatively easily simulate values in our tests with dynamically created objects. I encourage you to research the flavor of an isolation framework you’d like to use. I’ve only used substitute.js as an example in this book. It’s not the only framework out there.
该测试不需要手写假文,但请注意,它已经开始影响测试读者的可读性。功能性设计通常比这要简洁得多。在面向对象的环境中,有时这是一种不可避免的罪恶。然而,当我们重构代码时,我们可以轻松地将各种帮助器、模拟和桩的创建重构为帮助器函数,从而使测试变得更简单、更短,易于阅读。本书第 3 部分对此有更多介绍。
This test requires no handwritten fakes, but notice that it’s already starting to take a toll on the readability for the test reader. Functional designs are usually much slimmer than this. In an object-oriented setting, sometimes this is a necessary evil. However, we could easily refactor the creation of various helpers, mocks, and stubs to helper functions as we refactor our code, so that the test can be simpler and shorter to read. More on that in part 3 of this book.
Based on what we’ve covered in this chapter, we’ve seen distinct advantages to using isolation frameworks:
更容易的模块化伪造——如果没有一些样板代码,模块依赖关系可能很难解决,而隔离框架可以帮助我们消除这些样板代码。正如前面所解释的,这一点也可以算作负面因素,因为它鼓励我们将代码与第三方实现强耦合。
Easier modular faking—Module dependencies can be hard to get around without some boilerplate code, which isolation frameworks help us eliminate. This point can also be counted as a negative, as explained earlier, because it encourages us to have code strongly coupled to third-party implementations.
Easier simulation of values or errors—Writing mocks manually can be difficult across a complicated interface. Frameworks help a lot.
Easier fake creation—Isolation frameworks can be used to create both mocks and stubs more easily.
尽管使用隔离框架有很多优点,但也存在可能的危险。现在我们来谈谈一些需要注意的事情。
Although there are many advantages to using isolation frameworks, there are also possible dangers. Let’s now talk about a few things to watch out for.
隔离框架导致你陷入的最大陷阱是让你很容易伪造任何东西,并鼓励你首先认为你需要模拟对象。我并不是说您不需要桩,但模拟对象不应该成为大多数单元测试的标准操作过程。请记住,工作单元可以具有三种不同类型的退出点:返回值、状态更改和调用第三方依赖项。只有其中一种类型可以从测试中的模拟对象中受益。其他人则不然。
The biggest trap that isolation frameworks lead you into is making it easy to fake anything, and encouraging you to think you need mock objects in the first place. I’m not saying you won’t need stubs, but mock objects shouldn’t be the standard operating procedure for most unit tests. Remember that a unit of work can have three different types of exit points: return values, state change, and calling a third-party dependency. Only one of these types can benefit from a mock object in your test. The others don’t.
我发现,在我自己的测试中,模拟对象大约出现在 2%-5% 的测试中。其余的测试通常是返回值或基于状态的测试。对于功能设计,模拟对象的数量应该接近于零,除了一些极端情况。
I find that, in my own tests, mock objects are present in perhaps 2%-5% of my tests. The rest of the tests are usually return-value or state-based tests. For functional designs, the number of mock objects should be near zero, except for some corner cases.
如果您发现自己定义了一个测试并验证了一个对象或函数被调用,请仔细考虑是否可以在没有模拟对象的情况下证明相同的功能,而是通过验证返回值或整个工作单元的行为变化从外部(例如,验证函数是否抛出异常,而之前没有抛出异常)。Vladimir Khorikov 的单元测试原理、实践和模式(Manning,2020 年)的第 6 章详细描述了如何将基于交互的测试重构为更简单、更可靠的测试,以检查返回值。
If you find yourself defining a test and verifying that an object or function was called, think carefully whether you can prove the same functionality without a mock object, but instead by verifying a return value or a change in the behavior of the overall unit of work from the outside (for example, verifying that a function throws an exception when it didn’t before). Chapter 6 of Unit Testing Principles, Practices, and Patterns by Vladimir Khorikov (Manning, 2020) contains a detailed description of how to refactor interaction-based tests into simpler, more reliable tests that check a return value instead.
在测试中使用模拟会使测试的可读性降低一些,但仍然具有足够的可读性,以便局外人可以查看它并了解发生了什么。在单个测试中进行许多模拟或许多期望可能会破坏测试的可读性,因此很难维护,甚至很难理解正在测试的内容。
Using a mock in a test makes the test a little less readable, but still readable enough that an outsider can look at it and understand what’s going on. Having many mocks, or many expectations, in a single test can ruin the readability of the test so it’s hard to maintain, or even to understand what’s being tested.
如果您发现您的测试变得不可读或难以遵循,请考虑删除一些模拟或一些模拟期望,或者将测试分成几个更易读的较小测试。
If you find that your test becomes unreadable or hard to follow, consider removing some mocks or some mock expectations, or separating the test into several smaller tests that are more readable.
模拟对象允许您验证接口上是否调用了方法或调用了函数,但这并不一定意味着您正在测试正确的东西。许多刚刚接触测试的人最终只是因为他们可以验证事物,而不是因为它有意义。示例可能包括以下内容:
Mock objects allow you to verify that methods were called on your interfaces or that functions were called, but that doesn’t necessarily mean that you’re testing the right thing. A lot of people new to tests end up verifying things just because they can, not because it makes sense. Examples may include the following:
Verifying that an internal function calls another internal function (not an exit point).
Verifying that a stub was called (an incoming dependency should not be verified; it’s the overspecification antipattern, as we’ll discuss in section 5.6.5).
Verifying that something was called simply because someone told you to write a test, and you’re not sure what should really be tested. (This is a good time to verify that you’re understanding the requirements correctly.)
每次测试仅测试一个问题被认为是一种良好的做法。测试多个关注点可能会导致维护测试的混乱和问题。在测试中拥有两个模拟与测试同一工作单元的多个最终结果(多个退出点)相同。
It’s considered good practice to test only one concern per test. Testing more than one concern can lead to confusion and problems maintaining the test. Having two mocks in a test is the same as testing several end results of the same unit of work (multiple exit points).
对于每个出口点,考虑编写一个单独的测试,因为它可以被视为一个单独的需求。当您只测试一个问题时,您的测试名称也可能会变得更加集中和可读。如果您无法命名您的测试,因为它做了太多事情并且名称变得非常通用(例如“XWorksOK”),那么是时候将其分成多个测试了。
For each exit point, consider writing a separate test, as it could be considered a separate requirement. Chances are that your test names will also become more focused and readable when you only test one concern. If you can’t name your test because it does too many things and the name becomes very generic (e.g., “XWorksOK”), it’s time to separate it into more than one test.
如果您的测试有太多期望(x.received().X()、x.received().Y()等),它可能会变得非常脆弱,即使整体功能仍然有效,但生产代码的最轻微更改也会中断。测试交互是一把双刃剑:测试太多,你就会开始忽视大局——整体功能;测试太少,您就会错过工作单元之间的重要交互。
If your test has too many expectations (x.received().X(), x.received().Y(), and so on), it may become very fragile, breaking on the slightest of production code changes, even though the overall functionality still works. Testing interactions is a double-edged sword: test them too much, and you start to lose sight of the big picture—the overall functionality; test them too little, and you’ll miss the important interactions between units of work.
Here are some ways to balance this effect:
尽可能使用桩而不是模拟——如果超过 5% 的测试使用模拟对象,那么您可能做得太过分了。桩可以无处不在。嘲笑,没那么多。您一次只需测试一种场景。模拟越多,测试结束时进行的验证就越多,但通常只有一项是重要的。其余的将是针对当前测试场景的噪音。
Use stubs instead of mocks when you can—If more than 5% of your tests use mock objects, you might be overdoing it. Stubs can be everywhere. Mocks, not so much. You only need to test one scenario at a time. The more mocks you have, the more verifications will take place at the end of the test, but usually only one will be the important one. The rest will be noise against the current test scenario.
Avoid using stubs as mocks if possible—Use a stub only for faking simulated values into the unit of work under test or to throw exceptions. Don’t verify that methods were called on stubs.
隔离或模拟框架允许您以对象或函数形式动态创建、配置和验证模拟和桩。与手写的伪造相比,隔离框架节省了大量时间,尤其是在模块化依赖情况下。
Isolation, or mocking, frameworks allow you to dynamically create, configure, and verify mocks and stubs, either in object or function form. Isolation frameworks save a lot of time compared to handwritten fakes, especially in modular dependency situations.
隔离框架有两种类型:松散类型(例如 Jest 和 Sinon)和强类型(例如stitute.js)。松散类型的框架需要较少的样板文件,并且适合函数式代码;强类型框架在处理类和接口时非常有用。
There are two flavors of isolation frameworks: loosely typed (such as Jest and Sinon) and strongly typed (such as substitute.js). Loosely typed frameworks require less boilerplate and are good for functional-style code; strongly typed frameworks are useful when dealing with classes and interfaces.
Isolation frameworks can replace whole modules, but try to abstract away direct dependencies and fake those abstractions instead. This will help you reduce the amount of refactoring needed when the module’s API changes.
It's important to lean toward return-value or state-based testing as opposed to interaction testing whenever you can, so that your tests assume as little as possible about internal implementation details.
Mocks should be used only when there’s no other way to test the implementation, because they eventually lead to tests that are harder to maintain if you’re not careful.
根据您正在使用的代码库选择使用隔离框架的方式。在遗留项目中,您可能需要伪造整个模块,因为这可能是向此类项目添加测试的唯一方法。在新建项目中,尝试在第三方模块之上引入适当的抽象。这一切都在于为工作选择正确的工具,因此在考虑如何解决测试中的特定问题时一定要着眼于大局。
Choose the way you work with isolation frameworks based on the codebase you are working on. In legacy projects, you may need to fake whole modules, as it might be the only way to add tests to such projects. In greenfield projects, try to introduce proper abstractions on top of third-party modules. It’s all about picking the right tool for the job, so be sure to look at the big picture when considering how to approach a specific problem in testing.
done()等待done(), and awaits当我们处理常规同步代码时,等待操作完成是隐式的。我们并不担心这个问题,我们也没有想太多。然而,在处理异步代码时,等待操作完成成为我们控制下的显式活动。异步性使得代码以及该代码的测试可能变得更加棘手,因为我们必须明确等待操作完成。
When we’re dealing with regular synchronous code, waiting for actions to finish is implicit. We don’t worry about it, and we don’t really think about it too much. When dealing with asynchronous code, however, waiting for actions to finish becomes an explicit activity that is under our control. Asynchronicity makes code, and the tests for that code, potentially trickier because we have to be explicit about waiting for actions to complete.
Let’s start with a simple fetching example to illustrate the issue.
假设我们有一个模块可以检查我们的网站 example.com 是否处于活动状态。它通过从主 URL 获取上下文并检查特定单词“说明性”来确定网站是否正常运行来实现此目的。我们将研究此功能的两种不同且非常简单的实现。第一个使用callback机制,第二个使用async/await机制。
Let’s say we have a module that checks whether our website at example.com is alive. It does this by fetching the context from the main URL and checking for a specific word, “illustrative,” to determine if the website is up. We’ll look at two different and very simple implementations of this functionality. The first uses a callback mechanism, and the second uses an async/await mechanism.
图 6.1 说明了我们的目的的入口点和出口点。请注意,回调箭头的指向不同,以便更明显地表明它是不同类型的退出点。
Figure 6.1 illustrates their entry and exit points for our purposes. Note that the callback arrow is pointed differently, to make it more obvious that it’s a different type of exit point.
图 6.1IsWebsiteAlive()回调与async/await版本
Figure 6.1 IsWebsiteAlive() callback vs. the async/await version
初始代码如下面的清单所示。我们用来node-fetch获取 URL 的内容。
The initial code is shown in the following listing. We’re using node-fetch to get the URL’s content.
Listing 6.1 IsWebsiteAlive() callback and await versions
//回调版本
const fetch = require("节点获取");
const isWebsiteAliveWithCallback = (回调) => {
const 网站 = "http://example.com";
获取(网站)
.then((响应) => {
如果(!response.ok){
//我们如何模拟这个网络问题?
抛出错误(response.statusText); ❶
}
返回响应;
})
.then((响应) => 响应.text())
.then((文本) => {
if (text.includes("说明性")) {
回调({成功:true,状态:“确定”});
} 别的 {
//我们如何测试这条路径?
回调({ 成功: false, 状态: "文本丢失" });
}
})
.catch((错误) => {
//我们如何测试这个退出点?
回调({ 成功: false, 状态: 错误 });
});
};
// 等待版本
const isWebsiteAliveWithAsyncAwait = async () => {
尝试 {
const resp = wait fetch("http://example.com");
如果(!resp.ok){
//我们如何模拟一个不正常的响应?
抛出 resp.statusText; ❷
}
const text = wait resp.text();
const Include = text.includes("说明性");
如果(包含){
返回{成功:true,状态:“确定”};
}
// 我们如何模拟不同的网站内容?
抛出“文本缺失”;
} 捕获(错误){
返回 { 成功:错误,状态:错误 }; ❸
}
};//Callback version
const fetch = require("node-fetch");
const isWebsiteAliveWithCallback = (callback) => {
const website = "http://example.com";
fetch(website)
.then((response) => {
if (!response.ok) {
//how can we simulate this network issue?
throw Error(response.statusText); ❶
}
return response;
})
.then((response) => response.text())
.then((text) => {
if (text.includes("illustrative")) {
callback({ success: true, status: "ok" });
} else {
//how can we test this path?
callback({ success: false, status: "text missing" });
}
})
.catch((err) => {
//how can we test this exit point?
callback({ success: false, status: err });
});
};
// Await version
const isWebsiteAliveWithAsyncAwait = async () => {
try {
const resp = await fetch("http://example.com");
if (!resp.ok) {
//how can we simulate a non ok response?
throw resp.statusText; ❷
}
const text = await resp.text();
const included = text.includes("illustrative");
if (included) {
return { success: true, status: "ok" };
}
// how can we simulate different website content?
throw "text missing";
} catch (err) {
return { success: false, status: err }; ❸
}
};
❶ Throwing a custom error to handle problems in our code
❷ Throwing a custom error to handle problems in our code
❸ Wrapping the error into a response
注意在前面的代码中,我假设您知道 Promise 在 JavaScript 中如何工作。如果您需要更多信息,我建议您阅读http://mng.bz/W11a上有关 Promise 的 Mozilla 文档。
Note In the preceding code, I’m assuming you know how promises work in JavaScript. If you need more information, I recommend reading the Mozilla documentation on promises at http://mng.bz/W11a.
在此示例中,我们将连接失败或网页上缺少文本引起的任何错误转换为回调或返回值,以向函数用户表示失败。
In this example, we are converting any errors from connectivity failures or missing text on the web page to either a callback or a return value to denote a failure to the user of our function.
由于清单 6.1 中的所有内容都是硬编码的,您将如何测试它?您的最初反应可能涉及编写集成测试。以下清单显示了我们如何为回调版本编写集成测试。
Since everything is hardcoded in listing 6.1, how would you test this? Your initial reaction might involve writing an integration test. The following listing shows how we could write an integration test for the callback version.
Listing 6.2 An initial integration test
测试(“网络需要(回调):正确的内容,true”,(完成)=> {
样品。isWebsiteAliveWithCallback ((结果) => {
期望(结果.成功).toBe(true);
期望(结果。状态)。toBe(“确定”);
完毕();
});
});test("NETWORK REQUIRED (callback): correct content, true", (done) => {
samples.isWebsiteAliveWithCallback((result) => {
expect(result.success).toBe(true);
expect(result.status).toBe("ok");
done();
});
});
为了测试退出点是回调函数的函数,我们向它传递我们自己的回调函数,我们可以在其中
To test a function whose exit point is a callback function, we pass it our own callback function in which we can
Tell the test runner to stop waiting through whatever mechanism is given to us by the test framework (in this case, that’s the done() function)
因为我们使用回调作为退出点,所以我们的测试必须显式等待直到并行执行完成。并行执行可以在 JavaScript 事件循环上,也可以在单独的线程中,甚至在单独的进程中(如果您使用其他语言)。
Because we’re using callbacks as exit points, our test has to explicitly wait until the parallel execution completes. That parallel execution could be on the JavaScript event loop or it could be in a separate thread, or even in a separate process if you’re using another language.
在排列-行动-断言模式中,行动部分是我们需要等待的事情。大多数测试框架将允许我们使用特殊的辅助函数来做到这一点。在这种情况下,我们可以使用 Jest 提供的可选done回调来表明测试需要等待,直到我们显式调用done()。如果done()不调用,我们的测试将在默认的 5 秒后超时并失败(当然,这是可配置的)。
In the Arrange-Act-Assert pattern, the act part is the thing we need to wait out. Most test frameworks will allow us to do so with special helper functions. In this case, we can use the optional done callback that Jest provides to signal that the test needs to wait until we explicitly call done(). If done() isn’t called, our test will time out and fail after the default 5 seconds (which is configurable, of course).
Jest 还有其他测试异步代码的方法,我们将在本章后面介绍其中的几种方法。
Jest has other means for testing asynchronous code, a couple of which we’ll cover later in the chapter.
async/版本怎么样await?从技术上讲,我们可以编写一个看起来几乎与前一个测试完全相同的测试,因为async/await只是承诺的语法糖。
What about the async/await version? We could technically write a test that looks almost exactly like the previous one, since async/await is just syntactic sugar over promises.
Listing 6.3 Integration test with callbacks and .then()
测试(“需要网络(等待):正确的内容,true”,(完成)=> {
Samples.isWebsiteAliveWithAsyncAwait() .then( (结果) => {
期望(结果.成功).toBe(true);
期望(结果。状态)。toBe(“确定”);
完毕();
} ) ;
});test("NETWORK REQUIRED (await): correct content, true", (done) => {
samples.isWebsiteAliveWithAsyncAwait().then((result) => {
expect(result.success).toBe(true);
expect(result.status).toBe("ok");
done();
});
});
然而,使用诸如done()和 之类的回调的测试then()的可读性远低于使用 Arrange-Act-Assert 模式的测试。好消息是,没有必要强迫自己使用回调来使我们的生活变得复杂。await我们也可以在测试中使用该语法。这将迫使我们将async关键字放在测试函数的前面,但是,总的来说,我们的测试变得更简单且更具可读性,正如您在此处看到的那样。
However, a test that uses callbacks such as done() and then() is much less readable than one using the Arrange-Act-Assert pattern. The good news is there’s no need to complicate our lives by forcing ourselves to use callbacks. We can use the await syntax in our test as well. This will force us to put the async keyword in front of the test function, but, overall, our test becomes simpler and more readable, as you can see here.
Listing 6.4 Integration test with async/await
测试(“网络需要2(等待):正确的内容,true”,async()=> {
const result =等待样本.isWebsiteAliveWithAsyncAwait();
期望(结果.成功).toBe(true);
期望(结果。状态)。toBe(“确定”);
});test("NETWORK REQUIRED2 (await): correct content, true", async () => {
const result = await samples.isWebsiteAliveWithAsyncAwait();
expect(result.success).toBe(true);
expect(result.status).toBe("ok");
});
拥有允许我们使用async/语法的异步代码将我们的测试几乎await变成了基于值的普通测试。入口点也是出口点,如图 6.1 所示。
Having asynchronous code that allows us to use the async/await syntax turns our test into almost a run-of-the-mill value-based test. The entry point is also the exit point, as we saw in figure 6.1.
尽管调用被简化了,但底层的调用仍然是异步的,这就是为什么我仍然称其为集成测试。此类测试有哪些注意事项?来!我们讨论一下。
Even though the call is simplified, the call is still asynchronous underneath, which is why I still call this an integration test. What are the caveats for this type of test? Let’s discuss.
就集成测试而言,我们刚刚编写的测试并不可怕。它们相对较短且可读,但它们仍然受到任何集成测试的困扰:
The tests we’ve just written aren’t horrible as far as integration tests go. They’re relatively short and readable, but they still suffer from what any integration test suffers from:
Lengthy run time—Compared to unit tests, integration tests are orders of magnitude slower, sometimes taking seconds or even minutes.
Flaky—Integration tests can present inconsistent results (different timings based on where they run, inconsistent failures or successes, etc.)
测试可能不相关的代码和环境条件——集成测试测试可能与我们关心的内容无关的多段代码。(在我们的例子中,是node-fetch库、网络条件、防火墙、外部网站功能等)
Tests possibly irrelevant code and environment conditions—Integration tests test multiple pieces of code that might be unrelated to what we care about. (In our case, it’s the node-fetch library, network conditions, firewall, external website functionality, etc.)
Longer investigations—When an integration test fails, it requires more time for investigation and debugging because there are many possible reasons for a failure.
Simulation is harder—It is harder than it needs to be to simulate a negative test with an integration test (simulating wrong website content, website down, network down, etc.)
更难信任结果——我们可能认为集成测试的失败是由于外部问题造成的,而实际上这是我们代码中的错误。我将在下一章更多地讨论信任。
Harder to trust results—We might believe the failure of an integration test is due to an external issue when in fact it’s a bug in our code. I’ll talk about trust more in the next chapter.
这是否意味着您不应该编写集成测试?不,我相信您绝对应该进行集成测试,但您不需要进行那么多集成测试来对您的代码有足够的信心。任何集成测试未涵盖的内容都应该由较低级别的测试涵盖,例如单元、API 或组件测试。我将在第 10 章详细讨论这个策略,该章重点讨论测试策略。
Does all this mean you shouldn’t write integration tests? No, I believe you should absolutely have integration tests, but you don’t need to have that many of them to get enough confidence in your code. Whatever integration tests don’t cover should be covered by lower-level tests, such as unit, API, or component tests. I’ll discuss this strategy at length in chapter 10, which focuses on testing strategies.
我们如何通过单元测试来测试代码?我将向您展示一些我用来使代码更易于单元测试的模式(即,更轻松地注入或避免依赖项,并检查退出点):
How can we test the code with a unit test? I’ll show you some patterns that I use to make the code more unit testable (i.e., to more easily inject or avoid dependencies, and to check exit points):
Extract Entry Point pattern—Extracting the parts of the production code that are pure logic into their own functions, and treating those functions as entry points for our tests
Extract Adapter pattern—Extracting the thing that is inherently asynchronous and abstracting it away so that we can replace it with something that is synchronous
In this pattern, we take a specific unit of async work and split it into two pieces:
The callbacks that are invoked when the async execution finishes. These are extracted as new functions, which eventually become entry points for a purely logical unit of work that we can invoke with pure unit tests.
图 6.2 描述了这个想法:在上图中,我们有一个工作单元,其中包含与内部处理异步结果并通过回调或 Promise 机制返回结果的逻辑混合的异步代码。在步骤 1 中,我们将逻辑提取到其自己的函数中,该函数仅包含异步工作的结果作为输入。在步骤 2 中,我们将这些函数外部化,以便我们可以将它们用作单元测试的入口点。
Figure 6.2 depicts this idea: In the before diagram, we have a single unit of work that contains asynchronous code mixed with logic that processes the async results internally and returns a result via a callback or promise mechanism. In step 1, we extract the logic into its own function (or functions) that contains only the results of the async work as inputs. In step 2, we externalize those functions so that we can use them as entry points for our unit tests.
图 6.2 将内部处理逻辑提取到单独的工作单元中有助于简化测试,因为我们能够同步验证新的工作单元,而无需涉及外部依赖项。
Figure 6.2 Extracting the internal processing logic into a separate unit of work helps simplify the tests, because we are able to verify the new unit of work synchronously and without involving external dependencies.
这为我们提供了测试异步回调的逻辑处理(并轻松模拟输入)的重要能力。同时,我们可以选择针对原始工作单元编写更高级别的集成测试,以确保异步编排也能正常工作。
This provides us with the important ability to test the logical processing of the async callbacks (and to simulate inputs easily). At the same time, we can choose to write a higher-level integration test against the original unit of work to gain confidence that the async orchestration works correctly as well.
如果我们只对所有场景进行集成测试,我们最终会陷入一个充满大量长时间运行且不稳定的测试的世界。在新世界中,我们能够让大多数测试快速且一致,并在顶部进行一小层集成测试,以确保所有编排在其间正常工作。这样我们就不会为了信心而牺牲速度和可维护性。
If we do integration tests only for all our scenarios, we would end up in a world of many long-running and flaky tests. In the new world, we’re able to have most of our tests be fast and consistent, and to have a small layer of integration tests on top to make sure all the orchestration works in between. This way we don’t sacrifice speed and maintainability for confidence.
Example of extracting a unit of work
让我们将此模式应用到代码中来自清单 6.1。图 6.3 显示了我们将遵循的步骤:
Let’s apply this pattern to the code from listing 6.1. Figure 6.3 shows the steps we’ll follow:
❶ before状态包含嵌入到函数中的处理逻辑isWebsiteAlive()。
❶ The before state contains processing logic that is baked into the isWebsiteAlive() function.
❷我们将提取在获取结果边缘发生的任何逻辑代码,并将其放入两个单独的函数中:一个用于处理成功情况,另一个用于处理错误情况。
❷ We’ll extract any logical code that happens at the edge of the fetch results and put it in two separate functions: one for handling the success case, and the other for the error case.
❸然后我们将外部化这两个函数,以便我们可以直接从单元测试中调用它们。
❸ We’ll then externalize these two functions so that we can invoke them directly from unit tests.
图 6.3 从中提取成功和错误处理逻辑以isWebsiteAlive()分别测试该逻辑
Figure 6.3 Extracting the success and error-handling logic from isWebsiteAlive() to test that logic separately
The following listing shows the refactored code.
Listing 6.5 Extracting entry points with callback
//入口点
const isWebsiteAlive = (回调) => {
获取(“http://example.com”)
.then(抛出无效响应)
.then((resp) => resp.text())
.then((文本) => {
processFetchSuccess(文本, 回调);
})
.catch((错误) => {
processFetchError(错误,回调);
});
};
const throwOnInvalidResponse = (resp) => {
如果(!resp.ok){
抛出错误(resp.statusText);
}
返回响应;
};
//入口点
const processFetchSuccess = (text,callback) => { ❶
if (text.includes("说明性")) {
callback({ success: true, status: "ok" });
} else {
回调({ 成功: false, 状态: "缺少文本" });
}
};
//入口点
const processFetchError = (err,callback) => { ❶callback
({ success: false, status: err });
};//Entry Point
const isWebsiteAlive = (callback) => {
fetch("http://example.com")
.then(throwOnInvalidResponse)
.then((resp) => resp.text())
.then((text) => {
processFetchSuccess(text, callback);
})
.catch((err) => {
processFetchError(err, callback);
});
};
const throwOnInvalidResponse = (resp) => {
if (!resp.ok) {
throw Error(resp.statusText);
}
return resp;
};
//Entry Point
const processFetchSuccess = (text, callback) => { ❶
if (text.includes("illustrative")) {
callback({ success: true, status: "ok" });
} else {
callback({ success: false, status: "missing text" });
}
};
//Entry Point
const processFetchError = (err, callback) => { ❶
callback({ success: false, status: err });
};
❶ New entry points (units of work)
正如您所看到的,我们开始的原始单元现在有三个入口点,而不是我们开始时的单个入口点。新的入口点可用于单元测试,而原始入口点仍可用于集成测试,如图6.4所示。
As you can see, the original unit we started with now has three entry points instead of the single one we started with. The new entry points can be used for unit testing, while the original one can still be used for integration testing, as shown in figure 6.4.
图 6.4 提取两个新函数后引入的新入口点。现在可以使用更简单的单元测试来测试新功能,而不是重构之前所需的集成测试。
Figure 6.4 New entry points introduced after extracting the two new functions. The new functions can now be tested with simpler unit tests instead of the integration tests that were required before the refactoring.
我们仍然希望对原始入口点进行集成测试,但不超过其中一两个。任何其他场景都可以使用纯粹的逻辑入口点来快速、轻松地模拟。
We’d still want an integration test for the original entry point, but not more than one or two of those. Any other scenario can be simulated using the purely logical entry points, quickly and painlessly.
Now we’re free to write unit tests that invoke the new entry points, like this.
Listing 6.6 Unit tests with extracted entry points
描述(“网站活动检查”,()=> {
test("内容匹配,返回true", (done) => {
samples.processFetchSuccess("说明性", (err, 结果) => { ❶
期望(错误).toBeNull();
期望(结果.成功).toBe(true);
期望(结果。状态)。toBe(“确定”);
完毕();
});
});
test("网站内容不匹配,返回false", (done) => {
Samples.processFetchSuccess("不良内容", (err, result) => { ❶
Expect(err.message).toBe(“缺少文本”);
完毕();
});
});
test("当获取失败时,返回 false", (done) => {
Samples.processFetchError("错误文本", (err,结果) => { ❶
Expect(err.message).toBe(“错误文本”);
完毕();
});
});
});describe("Website alive checking", () => {
test("content matches, returns true", (done) => {
samples.processFetchSuccess("illustrative", (err, result) => { ❶
expect(err).toBeNull();
expect(result.success).toBe(true);
expect(result.status).toBe("ok");
done();
});
});
test("website content does not match, returns false", (done) => {
samples.processFetchSuccess("bad content", (err, result) => { ❶
expect(err.message).toBe("missing text");
done();
});
});
test("When fetch fails, returns false", (done) => {
samples.processFetchError("error text", (err,result) => { ❶
expect(err.message).toBe("error text");
done();
});
});
});
❶ Invoking the new entry points
请注意,我们直接调用新的入口点,并且我们能够轻松模拟各种条件。在这些测试中没有什么是异步的,但我们仍然需要该done()函数,因为回调可能根本不会被调用,并且我们希望捕获它。
Notice that we are invoking the new entry points directly, and we’re able to simulate various conditions easily. Nothing is asynchronous in these tests, but we still need the done() function, since the callbacks might not be invoked at all, and we’ll want to catch that.
我们仍然需要至少一项集成测试,让我们确信异步编排在我们的入口点之间正常工作。这就是原始集成测试可以提供帮助的地方,但我们不再需要将所有测试场景编写为集成测试(第 10 章将详细介绍这一点)。
We still need at least one integration test that gives us confidence that the asynchronous orchestration works between our entry points. That’s where the original integration test can help, but we don’t need to write all our test scenarios as integration tests anymore (more on this in chapter 10).
Extracting an entry point with await
我们刚刚应用的相同模式可以很好地适用于标准async/await函数结构。图 6.5 说明了这种重构。
The same pattern we just applied can work well for standard async/await function structures. Figure 6.5 illustrates that refactoring.
Figure 6.5 Extracting entry points with async/await
通过提供async/await语法,我们可以重新以线性方式编写代码,而无需使用回调参数。该isWebsiteAlive()函数开始看起来几乎与常规同步代码完全相同,仅在需要时返回值并抛出错误。
By providing the async/await syntax, we can go back to writing code in a linear fashion, without using callback arguments. The isWebsiteAlive() function starts looking almost exactly the same as regular synchronous code, only returning values and throwing errors when needed.
Listing 6.7 shows how that looks in our production code.
Listing 6.7 The function written with async/await instead of callbacks
//入口点
const isWebsiteAlive = async () => {
尝试 {
const resp = wait fetch("http://example.com");
throwIfResponseNotOK(resp);
const text = wait resp.text();
返回过程FetchContent(文本);
} 捕获(错误){
返回 processFetchError(err);
}
};
const throwIfResponseNotOK = (resp) => {
如果(!resp.ok){
抛出 resp.statusText;
}
};
//入口点
const processFetchContent = (文本) => {
const Include = text.includes("说明性");
如果(包含){
返回{成功:true,状态:“确定”}; ❶
}
return { success: false, status: "缺少文本" };
};
//入口点
const processFetchError = (err) => {
返回 { 成功:错误,状态:错误 }; ❶
};//Entry Point
const isWebsiteAlive = async () => {
try {
const resp = await fetch("http://example.com");
throwIfResponseNotOK(resp);
const text = await resp.text();
return processFetchContent(text);
} catch (err) {
return processFetchError(err);
}
};
const throwIfResponseNotOK = (resp) => {
if (!resp.ok) {
throw resp.statusText;
}
};
//Entry Point
const processFetchContent = (text) => {
const included = text.includes("illustrative");
if (included) {
return { success: true, status: "ok" }; ❶
}
return { success: false, status: "missing text" };
};
//Entry Point
const processFetchError = (err) => {
return { success: false, status: err }; ❶
};
❶ Returning a value instead of calling a callback
请注意,与回调示例不同,我们使用return或throw来表示成功或失败。async这是使用/编写代码的常见模式await。
Notice that, unlike the callback examples, we’re using return or throw to denote success or failure. This is a common pattern of writing code using async/await.
Our tests are simplified as well, as shown in the following listing.
Listing 6.8 Testing entry points extracted from async/await
描述(“网站上线检查”,()=> {
test("成功获取良好内容,返回 true", () => {
const 结果 = Samples.processFetchContent("说明性");
期望(结果.成功).toBe(true);
期望(结果。状态)。toBe(“确定”);
});
test("如果内容错误,则获取成功,返回 false", () => {
const result = Samples.processFetchContent("文本不在现场");
期望(结果.成功).toBe(假);
Expect(result.status).toBe("缺少文本");
});
测试(“获取失败时,抛出”,()=> {
Expect(() => Samples.processFetchError("错误文本"))
.toThrowError("错误文本");
});
});describe("website up check", () => {
test("on fetch success with good content, returns true", () => {
const result = samples.processFetchContent("illustrative");
expect(result.success).toBe(true);
expect(result.status).toBe("ok");
});
test("on fetch success with bad content, returns false", () => {
const result = samples.processFetchContent("text not on site");
expect(result.success).toBe(false);
expect(result.status).toBe("missing text");
});
test("on fetch fail, throws ", () => {
expect(() => samples.processFetchError("error text"))
.toThrowError("error text");
});
});
再次注意,我们不需要添加任何类型的async/await相关关键字或明确等待执行,因为我们已经将工作的逻辑单元与使我们的生活变得更加复杂的异步部分分开。
Again, notice that we don’t need to add any kind of async/await-related keywords or to be explicit about waiting for execution, because we’ve separated the logical unit of work from the asynchronous pieces that make our lives more complicated.
提取适配器模式采取与先前模式相反的观点。我们查看异步代码段就像查看前面章节中讨论的任何依赖项一样,作为我们希望在测试中替换的内容以获得更多控制。我们不会将逻辑代码提取到自己的入口点集中,而是提取异步代码(我们的依赖项)并将其抽象到适配器下,稍后我们可以注入该适配器,就像任何其他依赖项一样。图 6.6 显示了这一点。
The Extract Adapter pattern takes the opposite view from the previous pattern. We look at the asynchronous piece of code just like we look at any dependency we’ve discussed in the previous chapters—as something we’d like to replace in our tests to gain more control. Instead of extracting the logical code into its own set of entry points, we’ll extract the asynchronous code (our dependency) and abstract it away under an adapter, which we can later inject, just like any other dependency. Figure 6.6 shows this.
图 6.6 提取依赖项并用适配器包装它可以帮助我们简化该依赖项并在测试中将其替换为伪造的依赖项。
Figure 6.6 Extracting a dependency and wrapping it with an adapter helps us simplify that dependency and replace it with a fake in tests.
为适配器创建一个特殊的接口也很常见,该接口可以根据依赖项使用者的需求进行简化。这种方法的另一个名称是接口隔离原则。在本例中,我们将创建一个network-adapter隐藏真正的获取功能并具有自己的自定义函数的模块,如图 6.7 所示。
It’s also common to create a special interface for the adapter that is simplified for the needs of the consumer of the dependency. Another name for this approach is the interface segregation principle. In this case, we’ll create a network-adapter module that hides the real fetching functionality and has its own custom functions, as shown in figure 6.7.
图 6.7 包裹node-fetch与我们自己的模块network-adapter模块帮助我们仅公开应用程序所需的功能,并以最适合当前问题的语言表达。
Figure 6.7 Wrapping the node-fetch module with our own network-adapter module helps us expose only the functionality our application needs, expressed in the language most suitable for the problem at hand.
The following listing shows what the network-adapter module looks like.
Listing 6.9 The network-adapter code
const fetch = require("node-fetch");
const fetchUrlText = 异步(url)=> {
const resp = 等待 fetch(url);
if (resp.ok) {
const text = wait resp.text();
返回{确定:true,文本:文本};
}
return { ok: false, text: resp.statusText };
}; const fetch = require("node-fetch");
const fetchUrlText = async (url) => {
const resp = await fetch(url);
if (resp.ok) {
const text = await resp.text();
return { ok: true, text: text };
}
return { ok: false, text: resp.statusText };
};
请注意,该network-adapter模块是项目中唯一导入node-fetch. 如果该依赖关系在未来某个时刻发生变化,则这会增加仅当前文件需要更改的可能性。我们还通过名称和功能简化了该函数。我们隐藏了从 URL 获取状态和文本的需求,并将它们抽象为一个更易于使用的函数。
Note that the network-adapter module is the only module in the project that imports node-fetch. If that dependency changes at some point in the future, this increases the chances that only the current file would need to change. We’ve also simplified the function both by name and by functionality. We’re hiding the need to fetch the status and the text from the URL, and we’re abstracting them both under a single easier-to-use function.
现在我们可以选择如何使用适配器。首先,我们可以以模块化的方式使用它。然后我们将使用函数式方法和具有强类型接口的面向对象方法。
Now we get to choose how to use the adapter. First, we can use it in the modular style. Then we’ll use a functional approach and an object-oriented one with a strongly typed interface.
network-adapter 下面的清单显示了我们初始函数的 模块化使用isWebsiteAlive() 。
The following listing shows a modular use of network-adapter by our initial isWebsiteAlive() function.
清单 6.10isWebsiteAlive()使用network-adapter模块
Listing 6.10 isWebsiteAlive() using the network-adapter module
const 网络 = require("./network-adapter");
const isWebsiteAlive = async () => {
尝试 {
const result =等待network.fetchUrlText(“http://example.com”) ;
如果(!结果。确定){
抛出结果.text;
}
常量文本=结果.文本;
返回 processFetchSuccess(text);
} 捕获(错误){
抛出 processFetchFail(错误);
}
};const network = require("./network-adapter");
const isWebsiteAlive = async () => {
try {
const result = await network.fetchUrlText("http://example.com");
if (!result.ok) {
throw result.text;
}
const text = result.text;
return processFetchSuccess(text);
} catch (err) {
throw processFetchFail(err);
}
};
在此版本中,我们直接导入该network-adapter模块,稍后我们将在测试中伪造该模块。
In this version, we are directly importing the network-adapter module, which we’ll fake in our tests later on.
下面的列表显示了该模块的单元测试。jest.mock()因为我们使用模块化设计,所以我们可以伪造测试中使用的模块。我们还将在后面的示例中注入该模块,不用担心。
The unit tests for this module are shown in the following listing. Because we’re using a modular design, we can fake the module using jest.mock() in our tests. We’ll also inject the module in later examples, don’t worry.
清单 6.11network-adapter伪造jest.mock
Listing 6.11 Faking network-adapter with jest.mock
jest.mock("./网络适配器"); ❶
const StubSyncNetwork = require("./network-adapter"); ❷
const webverifier = require("./website-verifier");
描述(“单元测试网站验证器”,()=> {
beforeEach(jest.resetAllMocks); ❸
test("内容好,返回true", async () => {
StubSyncNetwork.fetchUrlText.mockReturnValue({ ❹
好的:是的,
文本:“说明性”,
});
const 结果 =等待webverifier.isWebsiteAlive(); ❺
期望(结果.成功).toBe(true);
期望(结果。状态)。toBe(“确定”);
});
test("内容不良,返回 false", async () => {
StubSyncNetwork.fetchUrlText.mockReturnValue({
好的:是的,
文本:“<span>你好世界</span>”,
});
const 结果 =等待webverifier.isWebsiteAlive(); ❺
期望(结果.成功).toBe(假);
Expect(result.status).toBe("缺少文本");
});jest.mock("./network-adapter"); ❶
const stubSyncNetwork = require("./network-adapter"); ❷
const webverifier = require("./website-verifier");
describe("unit test website verifier", () => {
beforeEach(jest.resetAllMocks); ❸
test("with good content, returns true", async () => {
stubSyncNetwork.fetchUrlText.mockReturnValue({ ❹
ok: true,
text: "illustrative",
});
const result = await webverifier.isWebsiteAlive(); ❺
expect(result.success).toBe(true);
expect(result.status).toBe("ok");
});
test("with bad content, returns false", async () => {
stubSyncNetwork.fetchUrlText.mockReturnValue({
ok: true,
text: "<span>hello world</span>",
});
const result = await webverifier.isWebsiteAlive(); ❺
expect(result.success).toBe(false);
expect(result.status).toBe("missing text");
});
❶ Faking the network-adapter module
❸ Resetting all the stubs to avoid any potential issues in other tests
❹ Simulating a return value from the stub module
请注意,我们再次使用async/ await,因为我们又回到了使用本章开头的原始入口点。但仅仅因为我们正在使用await并不意味着我们的测试是异步运行的。我们的测试代码及其调用的生产代码实际上是线性运行的,具有异步友好的签名。我们还需要将async/await用于功能和面向对象的设计,因为入口点需要它。
Notice that we are using async/await again, because we are back to using the original entry point we started with at the beginning of the chapter. But just because we’re using await doesn’t mean our tests are running asynchronously. Our test code, and the production code it invokes, actually runs linearly, with an async-friendly signature. We’ll need to use async/await for the functional and object-oriented designs as well, because the entry point requires it.
我命名了我们的假网络,stubSyncNetwork以使测试的同步性质更加清晰。否则,仅通过查看测试很难判断它调用的代码是线性运行还是异步运行。
I’ve named our fake network stubSyncNetwork to make the synchronous nature of the test clearer. Otherwise, it’s hard to tell just by looking at the test whether the code it invokes runs linearly or asynchronously.
在功能设计模式中,模块的设计network-adapter保持不变,但我们website-verifier以不同的方式将其注入到我们的模块中。正如您在下一个清单中看到的,我们向入口点添加了一个新参数。
In the functional design pattern, the design of the network-adapter module stays the same, but we enable its injection into our website-verifier differently. As you can see in the next listing, we add a new parameter to our entry point.
清单 6.12 一个功能注入设计isWebsiteAlive()
Listing 6.12 A functional injection design for isWebsiteAlive()
const isWebsiteAlive = async (网络) => {
const result =等待网络.fetchUrlText(“http://example.com”);
如果(结果.ok){
常量文本=结果.文本;
返回onFetchSuccess(文本);
}
返回 onFetchError(result.text);
};const isWebsiteAlive = async (network) => {
const result = await network.fetchUrlText("http://example.com");
if (result.ok) {
const text = result.text;
return onFetchSuccess(text);
}
return onFetchError(result.text);
};
在此版本中,我们期望network-adapter通过公共参数将模块注入到我们的函数中。在功能设计中,我们可以使用高阶函数和柯里化来配置具有我们自己的网络依赖项的预注入函数。在我们的测试中,我们可以简单地通过此参数发送一个假网络。就注入的设计而言,除了我们不再导入模块之外,与之前的示例相比几乎没有其他任何变化network-adapter。从长远来看,减少导入和需求量有助于可维护性。
In this version, we’re expecting the network-adapter module to be injected through a common parameter to our function. In a functional design, we can use higher-order functions and currying to configure a pre-injected function with our own network dependency. In our tests, we can simply send in a fake network via this parameter. As far as the design of the injection goes, almost nothing else has changed from previous samples, other than the fact that we don’t import the network-adapter module anymore. Reducing the amount of imports and requires can help maintainability in the long run.
Our tests are simpler in the following listing, with less boilerplate code.
清单 6.13 带有功能注入的单元测试network-adapter
Listing 6.13 Unit test with functional injection of network-adapter
const webverifier = require("./website-verifier");
❶
return {
fetchUrlText: () => {
return fakeResult;
},
};
};
描述(“单元测试网站验证器”,()=> {
test("内容好,返回true", async () => {
StubSyncNetwork = makeStubNetworkWithResult({
好的:是的,
文本:“说明性”,
});
const 结果 = 等待 webverifier.isWebsiteAlive( stubSyncNetwork ); ❷
期望(结果.成功).toBe(true);
期望(结果。状态)。toBe(“确定”);
});
test("内容不良,返回 false", async () => {
const StubSyncNetwork = makeStubNetworkWithResult({
好的:是的,
text: "意外内容",
});
const 结果 = 等待 webverifier.isWebsiteAlive( stubSyncNetwork ); ❷
期望(结果.成功).toBe(假);
Expect(result.status).toBe("缺少文本");
});
... const webverifier = require("./website-verifier");
❶
return {
fetchUrlText: () => {
return fakeResult;
},
};
};
describe("unit test website verifier", () => {
test("with good content, returns true", async () => {
stubSyncNetwork = makeStubNetworkWithResult({
ok: true,
text: "illustrative",
});
const result = await webverifier.isWebsiteAlive(stubSyncNetwork); ❷
expect(result.success).toBe(true);
expect(result.status).toBe("ok");
});
test("with bad content, returns false", async () => {
const stubSyncNetwork = makeStubNetworkWithResult({
ok: true,
text: "unexpected content",
});
const result = await webverifier.isWebsiteAlive(stubSyncNetwork); ❷
expect(result.success).toBe(false);
expect(result.status).toBe("missing text");
});
...
❶一个新的帮助函数,用于创建与网络适配器接口的重要部分相匹配的自定义对象
❶ A new helper function to create a custom object that matches the important parts of the network-adapter’s interface
请注意,我们不需要文件顶部的大量样板,就像我们在模块化设计中所做的那样。我们不需要间接伪造模块(通过jest.mock),我们不需要为我们的测试重新导入它(通过require),并且我们不需要使用 重置 Jest 的状态jest.resetAllMocks。我们需要做的就是makeStubNetworkWithResult从每个测试中调用新的辅助函数来生成一个新的假网络适配器,然后通过将其作为参数发送到我们的入口点来注入假网络。
Notice that we don’t need a lot of the boilerplate at the top of the file, as we did in the modular design. We don’t need to fake the module indirectly (via jest.mock), we don’t need to re-import it for our tests (via require), and we don’t need to reset Jest’s state using jest.resetAllMocks. All we need to do is call our new makeStubNetworkWithResult helper function from each test to generate a new fake network adapter, and then inject the fake network by sending it as a parameter to our entry point.
Object-oriented, interface-based adapter
我们研究了模块化和功能性设计。现在让我们将注意力转向等式的面向对象方面。在面向对象范式中,我们可以将之前完成的参数注入提升为构造函数注入模式。我们将从下面的清单中的网络适配器及其接口(公共 API 和结果签名)开始。
We’ve taken a look at the modular and functional designs. Let’s now turn our attention to the object-oriented side of the equation. In the object-oriented paradigm, we can take the parameter injection we’ve done before and promote it into a constructor injection pattern. We’ll start with the network adapter and its interfaces (public API and results signature) in the following listing.
Listing 6.14 NetworkAdapter and its interfaces
导出接口INetworkAdapter {
fetchUrlText(url: string): Promise<NetworkAdapterFetchResults>;
}
导出接口NetworkAdapterFetchResults {
好的:布尔值;
文本:字符串;
}
ch6-async/6-fetch-adapter-interface-oo/network-adapter.ts
导出类 NetworkAdapter实现 INetworkAdapter {
异步 fetchUrlText(url: string) :
Promise<NetworkAdapterFetchResults> {
const resp = 等待 fetch(url);
if (resp.ok) {
const text = wait resp.text();
return Promise.resolve({ ok: true, text: text });
}
return Promise.reject({ ok: false, text: resp.statusText });
}
}export interface INetworkAdapter {
fetchUrlText(url: string): Promise<NetworkAdapterFetchResults>;
}
export interface NetworkAdapterFetchResults {
ok: boolean;
text: string;
}
ch6-async/6-fetch-adapter-interface-oo/network-adapter.ts
export class NetworkAdapter implements INetworkAdapter {
async fetchUrlText(url: string):
Promise<NetworkAdapterFetchResults> {
const resp = await fetch(url);
if (resp.ok) {
const text = await resp.text();
return Promise.resolve({ ok: true, text: text });
}
return Promise.reject({ ok: false, text: resp.statusText });
}
}
在下一个清单中,我们创建一个WebsiteVerifier具有接收INetworkAdapter参数的构造函数的类。
In the next listing, we create a WebsiteVerifier class that has a constructor that receives an INetworkAdapter parameter.
清单 6.15WebsiteVerifier具有构造函数注入的类
Listing 6.15 WebsiteVerifier class with constructor injection
导出接口WebsiteAliveResult {
成功:布尔值;
状态:字符串;
}
导出类 WebsiteVerifier {
构造函数(私有网络:INetworkAdapter) {}
isWebsiteAlive = async (): Promise<WebsiteAliveResult> => {
让 netResult: NetworkAdapterFetchResults ;
尝试 {
netResult =等待this.network.fetchUrlText (“http://example.com”);
如果(!netResult.ok){
抛出netResult.text;
}
const 文本 = netResult.text;
返回 this.processNetSuccess(text);
} 捕获(错误){
抛出 this.processNetFail(err);
}
};
processNetSuccess =(文本):WebsiteAliveResult => {
const Include = text.includes("说明性");
如果(包含){
返回{成功:true,状态:“确定”};
}
return { success: false, status: "缺少文本" };
};
processNetFail = (err): WebsiteAliveResult => {
返回 { 成功:错误,状态:错误 };
};
}export interface WebsiteAliveResult {
success: boolean;
status: string;
}
export class WebsiteVerifier {
constructor(private network: INetworkAdapter) {}
isWebsiteAlive = async (): Promise<WebsiteAliveResult> => {
let netResult: NetworkAdapterFetchResults;
try {
netResult = await this.network.fetchUrlText("http://example.com");
if (!netResult.ok) {
throw netResult.text;
}
const text = netResult.text;
return this.processNetSuccess(text);
} catch (err) {
throw this.processNetFail(err);
}
};
processNetSuccess = (text): WebsiteAliveResult => {
const included = text.includes("illustrative");
if (included) {
return { success: true, status: "ok" };
}
return { success: false, status: "missing text" };
};
processNetFail = (err): WebsiteAliveResult => {
return { success: false, status: err };
};
}
此类的单元测试可以实例化一个假网络适配器并通过构造函数注入它。在下面的清单中,我们将使用 Replace.js 创建一个适合新界面的假对象。
The unit tests for this class can instantiate a fake network adapter and inject it through a constructor. In the following listing, we’ll use substitute.js to create a fake object that fits the new interface.
清单 6.16 面向对象的单元测试WebsiteVerifier
Listing 6.16 Unit tests for the object-oriented WebsiteVerifier
const makeStubNetworkWithResult = ( ❶ fakeResult: NetworkAdapterFetchResults ): INetworkAdapter => { const StubNetwork = Substitute.for<INetworkAdapter>(); ❷ StubNetwork.fetchUrlText(Arg.any()) .returns(Promise.resolve( fakeResult )); ❸ 返回桩网络; } ; 描述(“单元测试网站验证器”,()=> { test("内容好,返回true", async () => { const StubSyncNetwork = makeStubNetworkWithResult({ 好的:是的, 文本:“说明性”, }); const webVerifier = new WebsiteVerifier(stubSyncNetwork) ; const 结果 =等待 webVerifier.isWebsiteAlive(); 期望(结果.成功).toBe(true); 期望(结果。状态)。toBe(“确定”); }); test("内容不良,返回 false", async () => { const StubSyncNetwork = makeStubNetworkWithResult({ 好的:是的, text: "意外内容", }); const webVerifier = new WebsiteVerifier(stubSyncNetwork) ; const 结果 =等待 webVerifier.isWebsiteAlive(); 期望(结果.成功).toBe(假); Expect(result.status).toBe("缺少文本"); });
const makeStubNetworkWithResult = ( ❶ fakeResult: NetworkAdapterFetchResults ): INetworkAdapter => { const stubNetwork = Substitute.for<INetworkAdapter>(); ❷ stubNetwork.fetchUrlText(Arg.any()) .returns(Promise.resolve(fakeResult)); ❸ return stubNetwork; }; describe("unit test website verifier", () => { test("with good content, returns true", async () => { const stubSyncNetwork = makeStubNetworkWithResult({ ok: true, text: "illustrative", }); const webVerifier = new WebsiteVerifier(stubSyncNetwork); const result = await webVerifier.isWebsiteAlive(); expect(result.success).toBe(true); expect(result.status).toBe("ok"); }); test("with bad content, returns false", async () => { const stubSyncNetwork = makeStubNetworkWithResult({ ok: true, text: "unexpected content", }); const webVerifier = new WebsiteVerifier(stubSyncNetwork); const result = await webVerifier.isWebsiteAlive(); expect(result.success).toBe(false); expect(result.status).toBe("missing text"); });
❶ Helper function to simulate the network adapter
❸ Making the fake object return what the test requires
这种类型的控制反转(IOC)和依赖注入(DI) 效果很好。在面向对象的世界中,带有接口的构造函数注入非常常见,并且在许多情况下可以提供有效且可维护的解决方案,用于将依赖项与逻辑分离。
This type of Inversion of Control (IOC) and Dependency Injection (DI) works well. In the object-oriented world, constructor injection with interfaces is very common and can, in many instances, provide a valid and maintainable solution for separating your dependencies from your logic.
计时器,例如setTimeout,代表了一个非常特定于 JavaScript 的问题。它们是域的一部分,并且无论好坏,都在许多代码片段中使用。有时,禁用这些功能并解决它们同样有用,而不是提取适配器和入口点。我们将研究两种绕过计时器的模式:
Timers, such as setTimeout, represent a very JavaScript-specific problem. They are part of the domain and are used, for better or worse, in many pieces of code. Instead of extracting adapters and entry points, sometimes it’s just as useful to disable these functions and work around them. We’ll look at two patterns for getting around timers:
猴子补丁不在用于程序在本地扩展或修改支持系统软件(仅影响程序的运行实例)。JavaScript、Ruby 和 Python 等编程语言和运行时可以很容易地适应猴子补丁。对于 C# 和 Java 等强类型和编译时语言来说,做到这一点要困难得多。我在附录中更详细地讨论了猴子补丁。
Monkey-patching is a way for a program to extend or modify supporting system software locally (affecting only the running instance of the program). Programming languages and runtimes such as JavaScript, Ruby, and Python can accommodate monkey-patching pretty easily. It’s much more difficult to do with more strongly typed and compile-time languages such as C# and Java. I discuss monkey-patching in more detail in the appendix.
这是在 JavaScript 中执行此操作的一种方法。我们将从使用该方法的以下代码开始setTimeout。
Here’s one way to do it in JavaScript. We’ll start with the following piece of code that uses the setTimeout method.
Listing 6.17 Code with setTimeout we’d like to monkey-patch
constcalculate1 = (x, y, resultCallback) => {
setTimeout( () => { resultCallback(x + y); },
5000 );
};const calculate1 = (x, y, resultCallback) => {
setTimeout(() => { resultCallback(x + y); },
5000);
};
我们可以通过在内存中直接设置函数的原型来将函数猴子修补setTimeout为同步,如下所示。
We can monkey-patch the setTimeout function to be synchronous by literally setting that function’s prototype in memory, as follows.
Listing 6.18 A simple monkey-patching pattern
const Samples = require("./timing-samples");
描述(“猴子修补”,()=> {
让原始超时;
beforeEach(() => (originalTimeOut = setTimeout)); ❶
afterEach(() => (setTimeout = OriginalTimeOut)); ❷
测试(“计算1”,()=> {
setTimeout = (回调, 毫秒) => 回调(); ❸
Samples.calculate1(1, 2, (结果) => {
期望(结果).toBe(3);
});
});
});const Samples = require("./timing-samples");
describe("monkey patching ", () => {
let originalTimeOut;
beforeEach(() => (originalTimeOut = setTimeout)); ❶
afterEach(() => (setTimeout = originalTimeOut)); ❷
test("calculate1", () => {
setTimeout = (callback, ms) => callback(); ❸
Samples.calculate1(1, 2, (result) => {
expect(result).toBe(3);
});
});
});
❶ Saving the original setTimeout
❷ Restoring the original setTimeout
❸ Monkey-patching the setTimeout
由于一切都是同步的,因此我们不需要done()等待回调调用。我们将替换setTimeout为立即调用接收到的回调的纯同步实现。
Since everything is synchronous, we don’t need to use done() to wait for a callback invocation. We are replacing setTimeout with a purely synchronous implementation that invokes the received callback immediately.
这种方法的唯一缺点是它需要一堆样板代码,并且通常更容易出错,因为我们需要记住正确清理。让我们看看像 Jest 这样的框架为我们提供了哪些框架来处理这些情况。
The only downside to this approach is that it requires a bunch of boilerplate code and is generally more error prone, since we need to remember to clean up correctly. Let’s look at what frameworks like Jest provide us with to handle these situations.
Jest 为我们提供了三个主要函数来处理 JavaScript 中大多数类型的计时器:
Jest provides us with three major functions for handling most types of timers in JavaScript:
jest.useFakeTimers—Stubs out all the various timer functions, such as setTimetout
jest.advanceTimersToNextTimer—Triggers any fake timer so that any callbacks are triggered
Together, these functions take care of most of the boilerplate code for us.
这是我们刚刚在清单 6.18 中所做的相同测试,这次使用了 Jest 的辅助函数。
Here’s the same test we just did in listing 6.18, this time using Jest’s helper functions.
Listing 6.19 Faking setTimeout with Jest
描述(“计算1 - 用笑话”,()=> {
beforeEach(jest.clearAllTimers);
beforeEach(jest.useFakeTimers);
test("带回调的假超时", () => {
Samples.calculate1(1, 2, (结果) => {
期望(结果).toBe(3);
});
jest.advanceTimersToNextTimer();
});
});describe("calculate1 - with jest", () => {
beforeEach(jest.clearAllTimers);
beforeEach(jest.useFakeTimers);
test("fake timeout with callback", () => {
Samples.calculate1(1, 2, (result) => {
expect(result).toBe(3);
});
jest.advanceTimersToNextTimer();
});
});
请注意,我们不需要调用done(),因为一切都是同步的。同时,我们必须使用advanceTimersToNextTimer,因为没有它,我们的假货setTimeout将永远被卡住。advanceTimersToNextTimer对于诸如正在测试的模块调度一个模块,而setTimeout该模块的回调又递归地调度另一个模块setTimeout(意味着调度永远不会停止)等场景也很有用。在这些场景中,能够及时、一步一步地向前运行是很有用的。
Notice that, once again, we don’t need to call done(), since everything is synchronous. At the same time, we have to use advanceTimersToNextTimer because, without it, our fake setTimeout would be stuck forever. advanceTimersToNextTimer is also useful for scenarios such as when the module being tested schedules a setTimeout whose callback schedules another setTimeout recursively (meaning the scheduling never stops). In these scenarios, it’s useful to be able to run forward in time, step by step.
使用advanceTimersToNextTimer,您可以将所有计时器提前指定的步骤数,以模拟将触发排队等待的下一个计时器回调的步骤的通过。
With advanceTimersToNextTimer, you could potentially advance all timers by a specified number of steps to simulate the passage of steps that will trigger the next timer callback waiting in line.
The same pattern also works well with setInterval, as shown next.
Listing 6.20 A function that uses setInterval
constcalculate4 = (getInputsFn, resultFn) => {
setInterval(() => {
const { x, y } = getInputsFn();
结果Fn(x + y);
}, 1000);
};const calculate4 = (getInputsFn, resultFn) => {
setInterval(() => {
const { x, y } = getInputsFn();
resultFn(x + y);
}, 1000);
};
在这种情况下,我们的函数接受两个回调作为参数:一个用于提供计算输入,另一个用于回调计算结果。它用于setInterval不断获取更多输入并计算其结果。
In this case, our function takes in two callbacks as parameters: one to provide the inputs to calculate, and the other to call back with the calculation result. It uses setInterval to continuously get more inputs and calculate their results.
下面的清单显示了一个测试,它将提前我们的计时器,触发间隔两次,并期望两次调用得到相同的结果。
The following listing shows a test that will advance our timer, trigger the interval twice, and expect the same result from both invocations.
Listing 6.21 Advancing fake timers in a unit test
描述(“按间隔计算”,()=> {
beforeEach(jest.clearAllTimers);
beforeEach(jest.useFakeTimers);
test("计算,增加输入/输出,计算正确", () => {
让x输入= 1;
让 y 输入 = 2;
const inputFn = () => ({ x: xInput++, y: yInput++ }); ❶
常量结果 = [];
Samples.calculate4(inputFn, (结果) => results.push(结果));
jest.advanceTimersToNextTimer(); ❷
jest.advanceTimersToNextTimer(); ❷
期望(结果[0]).toBe(3);
期望(结果[1]).toBe(5);
});
});describe("calculate with intervals", () => {
beforeEach(jest.clearAllTimers);
beforeEach(jest.useFakeTimers);
test("calculate, incr input/output, calculates correctly", () => {
let xInput = 1;
let yInput = 2;
const inputFn = () => ({ x: xInput++, y: yInput++ }); ❶
const results = [];
Samples.calculate4(inputFn, (result) => results.push(result));
jest.advanceTimersToNextTimer(); ❷
jest.advanceTimersToNextTimer(); ❷
expect(results[0]).toBe(3);
expect(results[1]).toBe(5);
});
});
❶ Incrementing a variable to verify the number of callbacks
在此示例中,我们验证新值是否已正确计算和存储。请注意,我们可以仅使用一次调用和一次期望来编写相同的测试,并且我们将接近这个更复杂的测试提供的相同程度的置信度,但我喜欢在需要更多时进行额外的验证信心。
In this example, we verify that the new values are being calculated and stored correctly. Notice that we could have written the same test with only a single invocation and a single expect, and we would have gotten close to the same amount of confidence that this more elaborate test provides, but I like to put in additional validation when I need more confidence.
我不能谈论异步单元测试而不讨论基本事件流。希望异步单元测试的主题现在看起来相对简单,但我想明确地回顾一下事件部分。
I can’t talk about async unit testing and not discuss the basic events flow. Hopefully the topic of async unit testing now seems relatively straightforward, but I want to go over the events part explicitly.
为了确保我们都在同一页面上,以下是 DigitalOcean 的“在 Node.js 中使用事件发射器”教程 ( http://mng.bz/844z ) 中对事件发射器的清晰简洁的定义:
To make sure we’re all on the same page, here’s a clear and concise definition of event emitters from DigitalOcean’s “Using Event Emitters in Node.js” tutorial (http://mng.bz/844z):
事件发射器是 Node.js 中的对象,它们通过发送消息来指示操作已完成来触发事件。JavaScript 开发人员可以编写代码来侦听来自事件发射器的事件,从而允许他们在每次触发这些事件时执行函数。在此上下文中,事件由标识字符串和需要传递给侦听器的任何数据组成。
Event emitters are objects in Node.js that trigger an event by sending a message to signal that an action was completed. JavaScript developers can write code that listens to events from an event emitter, allowing them to execute functions every time those events are triggered. In this context, events are composed of an identifying string and any data that needs to be passed to the listeners.
考虑Adder下面清单中的类,它每次添加内容时都会发出一个事件。
Consider the Adder class in the following listing, which emits an event every time it adds something.
Listing 6.22 A simple event-emitter-based Adder
const EventEmitter = require("事件");
类 Adder扩展了 EventEmitter {
构造函数(){
极好的();
}
添加(x,y){
常量结果 = x + y;
this.emit("添加", 结果);
返回结果;
}
}const EventEmitter = require("events");
class Adder extends EventEmitter {
constructor() {
super();
}
add(x, y) {
const result = x + y;
this.emit("added", result);
return result;
}
}
编写验证事件是否已发出的单元测试的最简单方法是在我们的测试中订阅该事件,并验证它在我们调用该add函数时是否触发。
The simplest way to write a unit test that verifies that the event is emitted is to literally subscribe to the event in our test and verify that it triggers when we call the add function.
Listing 6.23 Testing an event emitter by subscribing to it
描述(“基于事件的模块”,()=> {
描述(“添加”,()=> {
it("调用时生成加法事件", ( done ) => {
常量加法器=新加法器();
adder.on("已添加", (结果) => {
期望(结果).toBe(3);
完成();
});
加法器.add(1, 2);
});
});
});describe("events based module", () => {
describe("add", () => {
it("generates addition event when called", (done) => {
const adder = new Adder();
adder.on("added", (result) => {
expect(result).toBe(3);
done();
});
adder.add(1, 2);
});
});
});
通过使用done(),我们正在验证事件是否确实已发出。如果我们不使用done(),并且事件没有发出,我们的测试就会通过,因为订阅的代码从未执行。通过添加expect(x).toBe(y),我们还验证事件参数中发送的值,并隐式测试事件是否被触发。
By using done(), we are verifying that the event actually was emitted. If we didn’t use done(), and the event wasn’t emitted, our test would pass because the subscribed code never executed. By adding expect(x).toBe(y), we are also verifying the values sent in the event parameters, as well as implicitly testing that the event was triggered.
那些烦人的 UI 事件(例如 )怎么办click?我们如何通过脚本测试是否正确绑定了它们?考虑清单 6.24 和 6.25 中的简单网页和相关逻辑。
What about those pesky UI events, such as click? How can we test that we have bound them correctly via our scripts? Consider the simple web page and associated logic in listings 6.24 and 6.25.
click清单 6.24 一个带有 JavaScript功能的简单网页
Listing 6.24 A simple web page with JavaScript click functionality
<!DOCTYPE html>
<html lang="en">
<头>
<元字符集=“UTF-8”>
<title>待测试文件</title>
<script src="index-helper.js"></script>
</头>
<正文>
<div>
<div>一个简单的按钮</div>
<Button data-testid="myButton" id="myButton">点击我</Button>
<div data-testid="myResult" id="myResult">等待...</div>
</div>
</正文>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>File to Be Tested</title>
<script src="index-helper.js"></script>
</head>
<body>
<div>
<div>A simple button</div>
<Button data-testid="myButton" id="myButton">Click Me</Button>
<div data-testid="myResult" id="myResult">Waiting...</div>
</div>
</body>
</html>
Listing 6.25 The logic for the web page in JavaScript
window.addEventListener("加载", () => {
文档
.getElementById("myButton")
.addEventListener("点击", onMyButtonClick);
const resultDiv = document.getElementById("myResult");
resultDiv.innerText = "文档已加载";
});
函数 onMyButtonClick() {
const resultDiv = document.getElementById("myResult");
resultDiv.innerText = "点击了!";
}window.addEventListener("load", () => {
document
.getElementById("myButton")
.addEventListener("click", onMyButtonClick);
const resultDiv = document.getElementById("myResult");
resultDiv.innerText = "Document Loaded";
});
function onMyButtonClick() {
const resultDiv = document.getElementById("myResult");
resultDiv.innerText = "Clicked!";
}
我们有一个非常简单的逻辑,可以确保我们的按钮在单击时设置特殊消息。我们如何测试这个?
We have a very simple piece of logic that makes sure our button sets a special message when clicked. How can we test this?
这是一个反模式:我们可以在测试中订阅点击事件并确保它被触发,但这对我们没有任何价值。我们关心的是,除了触发之外,点击实际上做了一些有用的事情。
Here’s an antipattern: we could subscribe to the click event in our tests and make sure it is triggered, but this would provide no value to us. What we care about is that the click has actually done something useful, other than triggering.
这是一个更好的方法:我们可以触发点击事件并确保它已更改页面内的正确值 - 这将提供真正的价值。图 6.8 显示了这一点。
Here’s a better way: we can trigger the click event and make sure it has changed the correct value inside the page—this will provide real value. Figure 6.8 shows this.
Figure 6.8 Click as an entry point, and element as an exit point
The following listing shows what our test might look like.
Listing 6.26 Triggering a click event, and testing an element’s text
/** * @jest-environment jsdom ❶ */ //(以上是窗口事件所需要的) const fs = require("fs"); const 路径 = require("路径"); 需要(“./index-helper.js”); const loadHtml = (fileRelativePath) => { const filePath = path.join(__dirname, "index.xhtml"); const innerHTML = fs.readFileSync(filePath); document.documentElement.innerHTML = innerHTML; }; const loadHtmlAndGetUIElements = () => { loadHtml("index.xhtml"); const 按钮 = document.getElementById("myButton"); const resultDiv = document.getElementById("myResult"); 返回 { 窗口,按钮,resultDiv }; }; 描述(“索引助手”,()=> { test("普通按钮点击触发结果 div 中的更改", () => { const { 窗口、按钮、resultDiv } = loadHtmlAndGetUIElements(); window.dispatchEvent(new Event("load")); ❷ 按钮.单击(); ❸ Expect(resultDiv.innerText).toBe(“点击了!”); ❹ }); });
/** * @jest-environment jsdom ❶ */ //(the above is required for window events) const fs = require("fs"); const path = require("path"); require("./index-helper.js"); const loadHtml = (fileRelativePath) => { const filePath = path.join(__dirname, "index.xhtml"); const innerHTML = fs.readFileSync(filePath); document.documentElement.innerHTML = innerHTML; }; const loadHtmlAndGetUIElements = () => { loadHtml("index.xhtml"); const button = document.getElementById("myButton"); const resultDiv = document.getElementById("myResult"); return { window, button, resultDiv }; }; describe("index helper", () => { test("vanilla button click triggers change in result div", () => { const { window, button, resultDiv } = loadHtmlAndGetUIElements(); window.dispatchEvent(new Event("load")); ❷ button.click(); ❸ expect(resultDiv.innerText).toBe("Clicked!"); ❹ }); });
❶ Applying the browser-simulating jsdom environment just for this file
❷ Simulating the document.load event
❹ Verifying that an element in our document has actually changed
在此示例中,我提取了两个实用程序方法loadHtml和loadHtmlAndGetUIElements,以便我可以编写更清晰、更易读的测试,并且如果将来 UI 项位置或 ID 发生更改,更改测试时会遇到更少的问题。
In this example, I’ve extracted two utility methods, loadHtml and loadHtmlAndGetUIElements, so that I can write cleaner, more readable tests, and so I’ll have fewer issues changing my tests if UI item locations or IDs change in the future.
在测试本身中,我们模拟document.load事件,以便我们的测试下的自定义脚本可以开始运行,然后触发click,就像用户单击了按钮一样。最后,测试验证文档中的元素实际上已更改,这意味着我们的代码成功订阅了事件并完成了工作。
In the test itself, we’re simulating the document.load event, so that our custom script under test can start running and then triggering the click, as if the user had clicked the button. Finally, the test verifies that an element in our document has actually changed, which means our code successfully subscribed to the event and did its work.
请注意,我们实际上并不关心索引帮助程序文件内的底层逻辑。我们只依赖于 UI 中观察到的状态变化,它作为我们的最终退出点。这允许我们的测试中更少的耦合,因此,如果我们的测试代码发生变化,我们不太可能需要更改测试,除非可观察的(公开可见的)功能确实发生了变化。
Notice that we don’t actually care about the underlying logic inside the index helper file. We just rely on observed state changes in the UI, which acts as our final exit point. This allows less coupling in our tests, so that if our code under test changes, we are less likely to need to change the test, unless the observable (publicly noticeable) functionality has truly changed.
我们的测试有很多样板代码,主要用于查找元素并验证其内容。我建议查看 Kent C. Dodds 编写的开源 DOM 测试库 ( https://github.com/kentcdodds/dom-testing-library-with-anything )。该库具有适用于当今大多数前端 JavaScript 框架的变体,例如 React、Angular 和 Vue.js。我们将使用它的普通版本,名为 DOM 测试库。
Our test has a lot of boilerplate code, mostly for finding elements and verifying their contents. I recommend looking into the open source DOM Testing Library written by Kent C. Dodds (https://github.com/kentcdodds/dom-testing-library-with-anything). This library has variants applicable to most frontend JavaScript frameworks today, such as React, Angular, and Vue.js. We’ll be using the vanilla version of it named DOM Testing Library.
我喜欢这个库的原因是它的目的是让我们能够更接近与网页交互的用户的角度来编写测试。我们不使用元素 ID,而是通过元素文本进行查询;触发事件更加干净一些;查询和等待元素出现或消失更干净,并且隐藏在语法糖下。一旦你在多次测试中使用它,它就非常有用。
What I like about this library is that it aims to allow us to write tests closer to the point of view of the user interacting with our web page. Instead of using IDs for elements, we query by element text; firing events is a bit cleaner; and querying and waiting for elements to appear or disappear is cleaner and hidden under syntactic sugar. It’s quite useful once you use it in multiple tests.
Here’s what our test looks like with this library.
Listing 6.27 Using the DOM Testing Library in a simple test
const { fireEvent, findByText, getByText } ❶
= require("@testing-library/dom"); ❶
const loadHtml = (fileRelativePath) => {
const filePath = path.join(__dirname, "index.xhtml");
const innerHTML = fs.readFileSync(filePath);
document.documentElement.innerHTML = innerHTML;
返回文档.documentElement; ❷
};
const loadHtmlAndGetUIElements = () => {
const docElem = loadHtml("index.xhtml");
const 按钮 = getByText(docElem, "点击我", { 精确: false });
返回 { 窗口, docElem , 按钮 };
};
描述(“索引助手”,()=> {
test("dom test lib按钮点击触发页面变化", () => {
const { 窗口,docElem,按钮 } = loadHtmlAndGetUIElements();
fireEvent.load(窗口); ❸
fireEvent.click(按钮); ❸
//等待 true 或 1 秒内超时
Expect(findByText(docElem,"clicked", { excact: false })).toBeTruthy(); ❹
});
});const { fireEvent, findByText, getByText } ❶
= require("@testing-library/dom"); ❶
const loadHtml = (fileRelativePath) => {
const filePath = path.join(__dirname, "index.xhtml");
const innerHTML = fs.readFileSync(filePath);
document.documentElement.innerHTML = innerHTML;
return document.documentElement; ❷
};
const loadHtmlAndGetUIElements = () => {
const docElem = loadHtml("index.xhtml");
const button = getByText(docElem, "click me", { exact: false });
return { window, docElem, button };
};
describe("index helper", () => {
test("dom test lib button click triggers change in page", () => {
const { window, docElem, button } = loadHtmlAndGetUIElements();
fireEvent.load(window); ❸
fireEvent.click(button); ❸
//wait until true or timeout in 1 sec
expect(findByText(docElem,"clicked", { exact: false })).toBeTruthy(); ❹
});
});
❶ Importing some of the library APIs to be used
❷ Library APIs require the document element as the basis for most of the work.
❸ Using the library’s fireEvent API to simplify event dispatching
❹ This query will wait until the item is found or will timeout within 1 second.
请注意该库如何允许我们使用页面项目的常规文本来获取项目,而不是它们的 ID 或测试 ID。这是图书馆推动我们工作的方式的一部分,因此从用户的角度来看,事情感觉更自然。为了使测试随着时间的推移更具可持续性,我们使用了该exact: false标志,这样我们就不必担心大写问题或字符串开头或结尾丢失字母。这消除了对不太重要的小文本更改更改测试的需要。
Notice how the library allows us to use the regular text of the page items to get the items, instead of their IDs or test IDs. This is part of the way the library pushes us to work so things feel more natural and from the user’s point of view. To make the test more sustainable over time, we’re using the exact: false flag so that we don’t have to worry about uppercasing issues or missing letters at the start or end of strings. This removes the need to change the test for small text changes that are less important.
Testing asynchronous code directly results in flaky tests that take a long time to execute. To fix these issues, you can take two approaches: extract an entry point or extract an adapter.
提取入口点是将纯逻辑提取到单独的函数中并将这些函数视为测试的入口点。提取的入口点可以接受回调作为参数,也可以返回一个值。为了简单起见,更喜欢返回值而不是回调。
Extracting an entry point is when you extract the pure logic into separate functions and treat those functions as entry points for your tests. The extracted entry point can either accept a callback as an argument or return a value. Prefer return values over callbacks for simplicity.
Extracting an adapter involves extracting a dependency that is inherently asynchronous and abstracting it away so that you can replace it with something that is synchronous. The adapter may be of different types:
Modular—When you stub the whole module (file) and replace specific functions in it.
Functional—When you inject a function or value into the system under test. You can replace the injected value with a stub in tests.
Object-oriented—When you use an interface in the production code and create a stub that implements that interface in the test code.
计时器(例如setTimeout和setInterval)可以直接用猴子补丁替换,也可以使用 Jest 或其他框架来禁用和控制它们。
Timers (such as setTimeout and setInterval) can be replaced either directly with monkey-patching or by using Jest or another framework to disable and control them.
最好通过验证事件产生的最终结果(用户可以看到的 HTML 文档中的更改)来测试事件。您可以直接执行此操作,也可以使用 DOM 测试库等库来执行此操作。
Events are best tested by verifying the end result they produce—changes in the HTML document the user can see. You can do this either directly or by using libraries such as the DOM Testing Library.
本部分涵盖了管理和组织单元测试以及确保实际项目中单元测试高质量的技术。
This part covers techniques for managing and organizing unit tests and for ensuring that the quality of unit tests in real-world projects is high.
第 7 章介绍了测试可信度。它解释了如何编写能够可靠地报告错误存在或不存在的测试。我们还将研究真实测试失败和错误测试失败之间的差异。
Chapter 7 covers test trustworthiness. It explains how to write tests that will reliably report the presence or absence of bugs. We’ll also look at the differences between true and false test failures.
在第 8 章中,我们将探讨良好单元测试的主要支柱——可维护性——并将探索支持它的技术。为了使测试长期有用,它们不应该需要太多的精力来维护;否则,他们将不可避免地被抛弃。
In chapter 8, we’ll look at the main pillar of good unit tests—maintainability—and we’ll explore techniques to support it. For tests to be useful in the long run, they shouldn’t require much effort to maintain; otherwise, they will inevitably become abandoned.
无论您如何组织测试,或者拥有多少测试,如果您不能信任它们、维护它们或阅读它们,它们就毫无价值。您编写的测试应该具有三个共同使它们变得良好的属性:
No matter how you organize your tests, or how many you have, they’re worth very little if you can’t trust them, maintain them, or read them. The tests that you write should have three properties that together make them good:
可信度——开发人员希望运行可信的测试,并且他们会充满信心地接受测试结果。值得信赖的测试没有错误,它们测试的是正确的东西。
Trustworthiness—Developers will want to run trustworthy tests, and they’ll accept the test results with confidence. Trustworthy tests don’t have bugs, and they test the right things.
可维护性——不可维护的测试是噩梦,因为它们可能会破坏项目进度,或者当项目的进度安排更加紧迫时,它们可能会被搁置。开发人员将停止维护和修复那些需要很长时间才能更改或需要经常更改的非常小的生产代码更改的测试。
Maintainability—Unmaintainable tests are nightmares because they can ruin project schedules, or they may be sidelined when the project is put on a more aggressive schedule. Developers will simply stop maintaining and fixing tests that take too long to change or that need to change often on very minor production code changes.
可读性——这不仅指能够阅读测试,还指在测试似乎错误时找出问题。如果没有可读性,其他两个支柱很快就会倒塌。维护测试变得更加困难,并且您不能再信任它们,因为您不理解它们。
Readability—This refers not only to being able to read a test but also figuring out the problem if the test seems to be wrong. Without readability, the other two pillars fall pretty quickly. Maintaining tests becomes harder, and you can’t trust them anymore because you don’t understand them.
本章和接下来的两章介绍了与每个支柱相关的一系列实践,您可以在进行测试评审时使用它们。三大支柱共同确保您的时间得到充分利用。丢掉其中一个,你就有可能浪费每个人的时间。
This chapter and the next two present a series of practices related to each of these pillars that you can use when doing test reviews. Together, the three pillars ensure your time is well used. Drop one of them, and you run the risk of wasting everyone’s time.
信任是我喜欢评估良好单元测试的三个支柱中的第一个,因此我们从它开始是合适的。如果我们不信任这些测试,那么运行它们还有什么意义呢?如果它们失败了,修复它们或修复代码有什么意义呢?维护它们有什么意义?
Trust is the first of the three pillars that I like to evaluate good unit tests on, so it’s fitting that we start with it. If we don’t trust the tests, what’s the point in running them? What’s the point in fixing them or fixing the code if they fail? What’s the point of maintaining them?
在测试环境中,“信任”对于软件开发人员意味着什么?也许根据测试失败或通过时我们做什么或不做什么来解释更容易。
What does “trust” mean for a software developer in the context of a test? Perhaps it’s easier to explain based on what we do or don’t do when a test fails or passes.
It fails and you’re not worried (you believe it’s a false positive).
You feel like it’s fine to ignore the results of this test, either because it passes every once in a while or because you feel it’s not relevant or buggy.
It passes and you are worried (you believe it’s a false negative).
You still feel the need to manually debug or test the software “just in case.”
The test fails and you’re genuinely worried that something broke. You don’t move on, assuming the test is wrong.
The test passes and you feel relaxed, not feeling the need to test or debug manually.
在接下来的几节中,我们将把测试失败作为识别不可信测试的一种方法,我们将研究通过测试的代码并了解如何检测不可信的测试代码。最后,我们将介绍一些可以增强测试可信度的通用实践。
In the next few sections, we’ll look at test failures as a way to identify untrustworthy tests, and we’ll look at passing tests’ code and see how to detect untrustworthy test code. Finally, we’ll cover a few generic practices that can enhance trustworthiness in tests.
理想情况下,您的测试(任何测试,而不仅仅是单元测试)应该只因有充分的理由而失败。当然,这个充分的理由是在底层生产代码中发现了一个真正的错误。
Ideally, your tests (any tests, not just unit tests) should only be failing for a good reason. That good reason is, of course, that a real bug was uncovered in the underlying production code.
不幸的是,测试可能会因多种原因而失败。我们可以假设测试因除一个充分理由之外的任何原因而失败应该触发“不可信”警告,但并非所有测试都以相同的方式失败,并且认识到测试可能失败的原因可以帮助我们为我们的目标制定路线图我想在每种情况下做。
Unfortunately, tests can fail for a multitude of reasons. We can assume that a test failing for any reason other than that one good reason should trigger an “untrustworthy” warning, but not all tests fail the same way, and recognizing the reasons tests may fail can help us build a roadmap for what we’d like to do in each case.
Here are some reasons that tests fail:
除了这里的第一点之外,所有这些原因都是测试告诉您它不应该以当前的形式被信任。让我们来看看它们。
Except for the first point here, all these reasons are the test telling you it should not be trusted in its current form. Let’s go through them.
测试失败的第一个原因是生产代码中存在错误。那挺好的!这就是我们进行测试的原因。让我们继续讨论测试失败的其他原因。
The first reason a test will fail is when there is a bug in the production code. That’s good! That’s why we have tests. Let’s move on to the other reasons tests fail.
如果测试有错误,测试就会失败。生产代码可能是正确的,但如果测试本身存在导致测试失败的错误,那也没关系。可能是您断言退出点的预期结果错误,或者您错误地使用了被测系统。可能是您错误地设置了测试上下文,或者您误解了应该测试的内容。
A test will fail if the test is buggy. The production code might be correct, but that doesn’t matter if the test itself has a bug that causes the test to fail. It could be that you’re asserting on the wrong expected result of an exit point, or that you’re using the system under test incorrectly. It could be that you’re setting up the context for the test wrong or that you misunderstand what you were supposed to test.
无论哪种方式,有错误的测试都可能非常危险,因为测试中的错误也可能导致测试通过,并使您不知道到底发生了什么。我们将在本章后面详细讨论不会失败但应该失败的测试。
Either way, a buggy test can be quite dangerous, because a bug in a test can also cause it to pass and leave you unsuspecting of what’s really going on. We’ll talk more about tests that don’t fail but should later in the chapter.
您的测试失败,但您可能已经调试了生产代码并且找不到任何错误。这时您应该开始怀疑失败的测试。没有办法解决这个问题。您将不得不慢慢调试测试代码。
You have a failing test, but you might have already debugged the production code and couldn’t find any bug there. This is when you should start suspecting the failing test. There’s no way around it. You’re going to have to slowly debug the test code.
Here are some potential causes of false failures:
这也可能是您在凌晨 2 点编写代码时发生的其他一些小错误(顺便说一句,这不是一个可持续的编码策略。停止这样做。)
It could also be some other small mistake that happens when you write code at 2 A.M. (That’s not a sustainable coding strategy, by the way. Stop doing that.)
What do you do once you’ve found a buggy test?
当您发现有错误的测试时,不要惊慌。这可能是您发现的第一百万次,因此您可能会感到恐慌并认为“我们的测试很糟糕”。你可能也是对的。但这并不意味着您应该惊慌。修复错误,然后运行测试以查看现在是否通过。
When you find a buggy test, don’t panic. This might be the millionth time you’ve found one, so you might be panicking and thinking “our tests suck.” You might also be right about that. But that doesn’t mean you should panic. Fix the bug, and run the test to see if it now passes.
如果测试通过了,别高兴得太早!转到生产代码并放置一个应该由新修复的测试捕获的明显错误。例如,将布尔值更改为始终为true。或者false。然后再次运行测试,并确保它失败。如果没有,您的测试中可能仍然存在错误。修复测试,直到找到生产错误并且您可以看到它失败。
If the test passes, don’t be happy too soon! Go to the production code and place an obvious bug that should be caught by the newly fixed test. For example, change a Boolean to always be true. Or false. Then run the test again, and make sure it fails. If it doesn’t, you might still have a bug in your test. Fix the test until it can find the production bug and you can see it fail.
一旦您确定测试因明显的生产代码问题而失败,请修复您刚刚提出的生产代码问题并再次运行测试。它应该过去。如果现在测试通过了,那么你就完成了。您现在已经看到测试在应该通过的时候通过,在应该失败的时候失败。提交代码并继续。
Once you are sure the test is failing for an obvious production code issue, fix the production code issue you just made and run the test again. It should pass. If the test is now passing, you’re done. You’ve now seen the test passing when it should and failing when it should. Commit the code and move on.
如果测试仍然失败,则可能存在另一个错误。再次重复整个过程,直到验证测试失败并在应该通过时通过。如果测试仍然失败,您可能在生产代码中遇到了真正的错误。在这种情况下,对你有好处!
If the test is still failing, it might have another bug. Repeat the whole process again until you verify that the test fails and passes when it should. If the test is still failing, you might have come across a real bug in production code. In which case, good for you!
How to avoid buggy tests in the future
据我所知,检测和防止错误测试的最佳方法之一是以测试驱动的方式编写代码。我在本书的第一章中对此技术进行了一些解释。我也在现实生活中练习这种技术。
One of the best ways I know to detect and prevent buggy tests is to write your code in a test-driven manner. I explained a bit about this technique in chapter 1 of this book. I also practice this technique in real life.
测试驱动开发 (TDD) 允许我们看到测试的两种状态:当它应该失败时(这是我们开始的初始状态)以及当它应该通过时(当被测试的生产代码写入时)使测试通过)。如果测试仍然失败,我们就在生产代码中发现了错误。如果测试开始通过,则测试中存在错误。
Test-driven development (TDD) allows us to see both states of a test: both that it fails when it should (that’s the initial state we start in) and that it passes when it should (when the production code under test is written to make the test pass). If the test continues to fail, we’ve found a bug in the production code. If the test starts out passing, we have a bug in the test.
减少测试中出现错误的可能性的另一个好方法是删除其中的逻辑。更多内容请参见第 7.3 节。
Another great way to reduce the likelihood of bugs in tests is to remove logic from them. More on this in section 7.3.
一个测试可以失败如果它不再与当前正在测试的功能兼容。假设您有登录功能,并且在早期版本中,您需要提供用户名和密码才能登录。在新版本中,双因素身份验证方案取代了旧的登录方式。现有测试将开始失败,因为它没有为登录功能提供正确的参数。
A test can fail if it’s no longer compatible with the current feature that’s being tested. Say you have a login feature, and in an earlier version, you needed to provide a username and a password to log in. In the new version, a two-factor authentication scheme replaced the old login. The existing test will start failing because it’s not providing the right parameters to the login functions.
Write a new test for the new functionality, and remove the old test because it has now become irrelevant.
Avoiding or preventing this in the future
事情会改变的。我认为在某个时间点不可能没有过时的测试。我们将在下一章中处理变更,涉及测试的可维护性以及测试如何处理应用程序中的变更。
Things change. I don’t think it’s possible to not have out-of-date tests at some point in time. We’ll deal with change in the next chapter, relating to the maintainability of tests and how well tests can handle changes in the application.
让我们假设您有两项测试:其中一项失败,一项通过。我们还假设他们不能一起通过。您通常只会看到失败的测试,因为通过的测试已经通过了。
Let’s say you have two tests: one of them is failing and one is passing. Let’s also say they cannot pass together. You’ll usually only see the failing test, because the passing one is, well, passing.
例如,测试可能会失败,因为它突然与新行为发生冲突。另一方面,冲突的测试可能期望新的行为,但没有找到它。最简单的示例是,第一个测试验证调用具有两个参数的函数会生成“3”,而第二个测试则期望相同的函数生成“4”。
For instance, a test may fail because it suddenly conflicts with a new behavior. On the other hand, a conflicting test may expect a new behavior but doesn’t find it. The simplest example is when the first test verifies that calling a function with two parameters produces “3,” whereas the second test expects the same function to produce “4.”
根本原因是其中一项测试变得无关紧要,这意味着需要将其删除。应该删除哪一个?这是我们需要问产品所有者的问题,因为答案与应用程序的正确行为和预期行为有关。
The root cause is that one of the tests has become irrelevant, which means it needs to be removed. Which one should be removed? That’s a question we’d need to ask a product owner, because the answer is related to which behavior is correct and expected from the application.
I feel this is a healthy dynamic, and I’m fine with not avoiding it.
测试可能会不一致地失败。即使被测试的生产代码没有更改,测试也可能会在没有任何明显原因的情况下突然失败,然后再次通过,然后再次失败。我们称这样的测试为“片状”。
A test can fail inconsistently. Even if the production code under test hasn’t changed, a test can suddenly fail without any apparent reason, then pass again, then fail again. We call a test like that “flaky.”
Flaky 测试是一种特殊的野兽,我将在 7.5 节中处理它们。
Flaky tests are a special beast, and I’ll deal with them in section 7.5.
当您在测试中包含越来越多的逻辑时,测试中出现错误的可能性几乎呈指数级增加。我见过很多本应简单的测试变成了动态的、随机数生成的、线程创建的、文件写入的怪物,它们本身就是小测试引擎。可悲的是,因为它们是“测试”,所以作者没有考虑到它们可能存在错误或没有以可维护的方式编写它们。这些测试怪物花费的调试和验证时间比它们节省的时间还要多。
The chances of having bugs in your tests increase almost exponentially as you include more and more logic in them. I’ve seen plenty of tests that should have been simple become dynamic, random-number-generating, thread-creating, file-writing monsters that are little test engines in their own right. Sadly, because they were “tests,” the writer didn’t consider that they might have bugs or didn’t write them in a maintainable manner. Those test monsters take more time to debug and verify than they save.
但所有的怪物都是从小开始的。通常,公司中经验丰富的开发人员会查看测试并开始思考:“如果我们让函数循环并创建随机数作为输入怎么办?这样我们肯定会发现更多的错误!” 你会的,尤其是在测试中。
But all monsters start out small. Often, an experienced developer in the company will look at a test and start thinking, “What if we made the function loop and create random numbers as input? We’d surely find lots more bugs that way!” And you will, especially in your tests.
测试错误是开发人员最烦人的事情之一,因为您几乎永远不会在测试本身中寻找失败测试的原因。我并不是说逻辑测试没有任何价值。事实上,在某些特殊情况下我可能会自己编写这样的测试。但我尽量避免这种做法。
Test bugs are one of the most annoying things for developers, because you’ll almost never search for the cause of a failing test in the test itself. I’m not saying that tests with logic don’t have any value. In fact, I’m likely to write such tests myself in some special situations. But I try to avoid this practice as much as possible.
如果单元测试中有以下任何内容,则您的测试包含我通常建议减少或完全删除的逻辑:
If you have any of the following inside a unit test, your test contains logic that I usually recommend be reduced or removed completely:
Here’s a quick example of a concatenation to start us off.
Listing 7.1 A test with logic in it
描述(“makeGreeting”,()=> {
it("返回正确的姓名问候语", () => {
常量名称=“abc”;
const 结果 = trust.makeGreeting(name);
Expect(结果).toBe(“你好”+名字); ❶
});describe("makeGreeting", () => {
it("returns correct greeting for name", () => {
const name = "abc";
const result = trust.makeGreeting(name);
expect(result).toBe("hello" + name); ❶
});
为了了解此测试的问题,以下清单显示了正在测试的代码。请注意,该+标志同时出现在两者中。
To understand the problem with this test, the following listing shows the code being tested. Notice that the + sign makes an appearance in both.
const makeGreeting = (name) => {
return "hello" + name; } ❶
};const makeGreeting = (name) => {
return "hello" + name; ❶
};
❶ The same logic as in the production code
请注意连接名称与字符串的算法(非常简单,但仍然是一个算法)如何"hello"在测试和被测代码中重复:
Notice how the algorithm (very simple, but still an algorithm) of connecting a name with a "hello" string is repeated in both the test and the code under test:
Expect(结果).toBe(“你好”+名字); ❶ 返回“你好”+名字; ❷
expect(result).toBe("hello" + name); ❶
return "hello" + name; ❷
我对这个测试的问题是被测算法在测试本身中重复。这意味着如果算法中存在错误,则测试也包含相同的错误。测试不会捕获错误,而是期望被测试的代码得到不正确的结果。
My issue with this test is that the algorithm under test is repeated in the test itself. This means that if there is a bug in the algorithm, the test also contains the same bug. The test will not catch the bug, but instead will expect the incorrect result from the code under test.
在这种情况下,错误的结果是我们在连接的单词之间缺少空格字符,但希望您可以看到使用更复杂的算法如何使同一问题变得更加复杂。
In this case, the incorrect result is that we’re missing a space character between the concatenated words, but hopefully you can see how the same issue could become much more complex with a more complex algorithm.
这是一个信任问题。我们不能相信这个测试会告诉我们真相,因为它的逻辑是正在测试的逻辑的重复。当代码中存在错误时,测试可能会通过,因此我们不能相信测试的结果。
This is a trust issue. We can’t trust this test to tell us the truth, since its logic is a repeat of the logic being tested. The test might pass when the bug exists in the code, so we can’t trust the test’s result.
Warning Avoid dynamically creating the expected value in your asserts; use hardcoded values when possible.
A more trustworthy version of this test can be rewritten as follows.
Listing 7.3 A more trustworthy test
it("返回名字 2 的正确问候语", () => {
const 结果 = trust.makeGreeting("abc");
期望(结果).toBe( “你好abc” ); ❶
});it("returns correct greeting for name 2", () => {
const result = trust.makeGreeting("abc");
expect(result).toBe("hello abc"); ❶
});
由于此测试中的输入非常简单,因此很容易编写硬编码的期望值。这是我通常建议的做法——使测试输入如此简单,以便创建预期值的硬编码版本。请注意,单元测试大多如此。对于更高级别的测试,这有点难做到,这也是为什么更高级别的测试应该被认为风险更大的另一个原因;它们经常动态地创建预期结果,您应该尽可能避免这种情况。
Because the inputs in this test are so simple, it’s easy to write a hardcoded expected value. This is what I usually recommend—make the test inputs so simple that it is trivial to create a hardcoded version of the expected value. Note that this is mostly true of unit tests. For higher-level tests, this is a bit harder to do, which is another reason why higher-level tests should be considered a bit riskier; they often create expected results dynamically, which you should try to avoid any time you can.
“但是罗伊,”你可能会说,“现在我们正在重复自己——字符串"abc"重复了两次。在之前的测试中我们能够避免这种情况。” 当紧要关头,信任应该胜过可维护性。我不能信任的高度可维护的测试有什么用呢?您可以在 Vladimir Khorikov 的文章“单元测试中的 DRY 与 DAMP”( https://enterprisecraftsmanship.com/posts/dry-damp-unit-tests/ ) 中了解有关测试中代码重复的更多信息。
“But Roy,” you might say, “Now we are repeating ourselves—the string "abc" is repeated twice. We were able to avoid this in the previous test.” When push comes to shove, trust should trump maintainability. What good is a highly maintainable test that I cannot trust? You can read more about code duplication in tests in Vladimir Khorikov’s article, “DRY vs. DAMP in Unit Tests,” (https://enterprisecraftsmanship.com/posts/dry-damp-unit-tests/).
这是相反的情况:动态创建输入(使用循环)迫使我们动态决定预期输出应该是什么。假设我们有以下代码要测试。
Here’s the opposite case: creating the inputs dynamically (using a loop) forces us to dynamically decide what the expected output should be. Suppose we have the following code to test.
Listing 7.4 A name-finding function
const isName = (输入) => {
return input.split(" ").length === 2;
};const isName = (input) => {
return input.split(" ").length === 2;
};
The following listing shows a clear antipattern for a test.
Listing 7.5 Loops and ifs in a test
描述(“isName”,()=> {
const namesToTest = [“firstOnly”,“第一第二”,“”]; ❶
it("正确判断它是否是一个名字", () => {
nameToTest.forEach((名称) => {
const 结果 = trust.isName(name);
if (name.includes(" ")) { ❷
Expect(结果).toBe(true); ❷
} else { ❷
期望(结果).toBe(假); ❷
}
});
});
});describe("isName", () => {
const namesToTest = ["firstOnly", "first second", ""]; ❶
it("correctly finds out if it is a name", () => {
namesToTest.forEach((name) => {
const result = trust.isName(name);
if (name.includes(" ")) { ❷
expect(result).toBe(true); ❷
} else { ❷
expect(result).toBe(false); ❷
}
});
});
});
❷ Production code logic leaking into the test
请注意我们如何使用多个输入进行测试。这迫使我们循环这些输入,这本身就使测试变得复杂。请记住,循环也可能有错误。
Notice how we’re using multiple inputs for the test. This forces us to loop over those inputs, which in itself complicates the test. Remember, loops can have bugs too.
此外,因为我们对值有不同的场景(带空格和不带空格),所以我们需要一个if/else来了解断言所期望的内容,并且if/else也可能有错误。我们还重复了生产算法的一部分,这让我们回到了之前的串联示例及其问题。
Additionally, because we have different scenarios for the values (with and without spaces) we need an if/else to know what the assertion is expecting, and the if/else can have bugs too. We are also repeating a part of the production algorithm, which brings us back to the previous concatenation example and its problems.
最后,我们的测试名称太通用了。我们只能将其称为“它有效”,因为我们必须考虑多种场景和预期结果。这对可读性不利。
Finally, our test name is too generic. We can only title it as “it works” because we have to account for multiple scenarios and expected outcomes. That’s bad for readability.
这是一次全面的糟糕测试。最好将其分成两个或三个测试,每个测试都有自己的场景和名称。这将允许我们使用硬编码的输入和断言,并从代码中删除任何循环和if/逻辑。else任何更复杂的情况都会导致以下问题:
This is an all-around bad test. It’s better to separate this into two or three tests, each with its own scenario and name. This would allow us to use hardcoded inputs and assertions and to remove any loops and if/else logic from the code. Anything more complex causes the following problems:
The test is hard to recreate. For example, imagine a multithreaded test or a test with random numbers that suddenly fails.
The test is more likely to have a bug or to verify the wrong thing.
Naming the test may be harder because it does multiple things.
一般来说,怪物测试会取代原来的简单测试,这使得在生产代码中发现错误变得更加困难。如果您必须创建一个怪物测试,则应将其添加为新测试,而不是替代现有测试。此外,它应该驻留在一个明确标题为保存单元测试以外的测试的项目或文件夹中。我将这些称为“集成测试”或“复杂测试”,并尝试将其数量保持在可接受的最低限度。
Generally, monster tests replace original simpler tests, and that makes it harder to find bugs in the production code. If you must create a monster test, it should be added as a new test and not be a replacement for existing tests. Also, it should reside in a project or folder explicitly titled to hold tests other than unit tests. I call these “integration tests” or “complex tests” and try to keep their number to an acceptable minimum.
逻辑不仅可以在测试中找到,还可以在测试辅助方法、手写假货和测试实用程序类中找到。请记住,您在这些位置添加的每一段逻辑都会使代码更难以阅读,并增加测试使用的实用程序方法中出现错误的机会。
Logic can be found not only in tests but also in test helper methods, handwritten fakes, and test utility classes. Remember, every piece of logic you add in these places makes the code that much harder to read and increases the chances of a bug in a utility method that your tests use.
如果您发现由于某种原因需要在测试套件中包含复杂的逻辑(尽管这通常是我在集成测试中所做的事情,而不是单元测试),至少确保您对实用程序方法的逻辑进行了一些测试在测试项目中。这会让你在路上省去很多眼泪。
If you find that you need to have complicated logic in your test suite for some reason (though that’s generally something I do with integration tests, not unit tests), at least make sure you have a couple of tests against the logic of your utility methods in the test project. This will save you many tears down the road.
我们现在已经介绍了失败的测试,作为检测我们不应该信任的测试的方法。我们遍布各地的那些安静、绿色的测试怎么样?我们应该相信他们吗?在将测试推入主分支之前,我们需要对其进行代码审查,那又如何呢?我们应该寻找什么?
We’ve now covered failed tests as a means of detecting tests we shouldn’t trust. What about all those quiet, green tests we have lying all over the place? Should we trust them? What about a test that we need to do a code review for, before it’s pushed into a main branch? What should we look for?
让我们使用术语“错误信任”来描述您确实不应该信任但您还不知道的测试。能够审查测试并发现可能的错误信任问题具有巨大的价值,因为您不仅可以自己修复这些测试,而且还会影响其他所有要阅读或运行这些测试的人的信任。以下是我降低对测试信任度的一些原因,即使它们通过了:
Let’s use the term “false-trust” to describe trusting a test that you really shouldn’t, but you don’t know it yet. Being able to review tests and find possible false-trust issues has immense value because, not only can you fix those tests yourself, you’re affecting the trust of everyone else who’s ever going to read or run those tests. Here are some reasons I reduce my trust in tests, even if they are passing:
我们都同意,不能真正验证某件事是真是假的测试没有什么帮助,对吗?没什么帮助,因为它还会花费维护时间、重构和阅读时间,如果由于生产代码中的 API 更改而需要更改,有时还会产生不必要的噪音。
We all agree that a test that doesn’t actually verify that something is true or false is less than helpful, right? Less than helpful because it also costs in maintenance time, refactoring, and reading time, and sometimes unnecessary noise if it needs changing due to API changes in production code.
如果您看到没有断言的测试,请考虑函数调用中可能存在隐藏的断言。如果函数没有被命名来解释这一点,这会导致可读性问题。有时,人们还编写一个测试来执行一段代码,只是为了确保代码不会引发异常。这确实有一定的价值,如果这是您选择编写的测试,请确保测试的名称使用诸如“不抛出”之类的术语来表明这一点。更具体地说,许多测试 API 支持指定某些内容不引发异常的功能。您可以在 Jest 中执行此操作:
If you see a test with no asserts, consider that there may be hidden asserts in a function call. This causes a readability problem if the function is not named to explain this. Sometimes people also write a test that exercises a piece of code simply to make sure that the code does not throw an exception. This does have some value, and if that’s the test you choose to write, make sure that the name of the test indicates this with a term such as “does not throw.” To be even more specific, many test APIs support the ability to specify that something does not throw an exception. This is how you can do this in Jest:
Expect(() => someFunction()).not.toThrow(错误)
expect(() => someFunction()).not.toThrow(error)
如果确实有此类测试,请确保其数量非常少。我不建议将其作为标准,但仅适用于非常特殊的情况。
If you do have such tests, make sure there’s a very small number of them. I don’t recommend it as a standard, but only for really special cases.
有时,人们只是由于缺乏经验而忘记写断言。考虑添加缺少的断言或删除测试(如果它没有带来任何价值)。人们还可能积极编写测试来实现管理层设定的一些想象的测试覆盖率目标。这些测试通常没有任何实际价值,只是让人们摆脱管理层的束缚,以便他们能够做真正的工作。
Sometimes people simply forget to write an assert due to lack of experience. Consider adding the missing assert or removing the test if it brings no value. People may also actively write tests to achieve some imagined test coverage goal set by management. Those tests usually serve no real value except to get management off people’s backs so they can do real work.
提示代码覆盖率本身不应该成为一个目标。它并不意味着“代码质量”。事实上,它经常导致开发人员编写无意义的测试,这将花费更多的时间来维护。相反,衡量“逃逸的错误”、“修复时间”以及我们将在第 11 章中讨论的其他指标。
TIP Code coverage shouldn’t ever be a goal on its own. It doesn’t mean “code quality.” In fact, it often causes developers to write meaningless tests that will cost even more time to maintain. Instead, measure “escaped bugs,” “time to fix,” and other metrics that we’ll discuss in chapter 11.
这是一个很大的问题,我将在第 9 章中深入讨论它。有几个可能的问题:
This is a huge issue, and I’ll deal with it in depth in chapter 9. There are several possible issues:
Tests containing hidden logic or assumptions that cannot be understood easily
Test results that are inconclusive (neither failed nor passed)
If you don’t understand the test that’s failing or passing, you don’t know if you should be worried or not.
他们说一个烂苹果毁了一堆苹果。对于与非片状测试混合的片状测试也是如此。集成测试比单元测试更有可能不稳定,因为它们具有更多的依赖性。如果您发现同一文件夹或测试执行命令中混合有集成测试和单元测试,您应该感到怀疑。
They say that one rotten apple spoils the bunch. The same is true for flaky tests mixed in with nonflaky tests. Integration tests are much more likely to be flaky than unit tests because they have more dependencies. If you find that you have a mix of integration and unit tests in the same folder or test execution command, you should be suspicious.
人类喜欢走阻力最小的路,在编码方面也不例外。假设开发人员运行了所有测试,其中一个测试失败了,如果有一种方法可以将其归咎于缺少配置或网络问题,而不是花时间调查和解决实际问题,那么他们就会这么做。如果他们面临严重的时间压力,或者他们过度致力于交付已经迟到的事情,则尤其如此。
Humans like to take the path of least resistance, and it’s no different when it comes to coding. Suppose that a developer runs all the tests and one of them fails—if there’s a way to blame a missing configuration or a network issue instead of spending time investigating and fixing a real problem, they will. That’s especially true if they’re under serious time pressure or they’re overcommitted to delivering things they’re already late on.
最简单的事情就是指责任何失败的测试是不稳定的测试。因为片状和非片状测试相互混合,所以这是一件简单的事情,也是忽略问题并从事更有趣的事情的好方法。由于这种人为因素,最好删除将不可靠的测试归咎于该选项。你应该做什么来防止这种情况发生?通过将集成和单元测试放在不同的地方来建立一个安全的绿色区域。
The easiest thing is to accuse any failing test of being a flaky test. Because flaky and nonflaky tests are mixed up with each other, that’s a simple thing to do, and it’s a good way to ignore the issue and work on something more fun. Because of this human factor, it’s best to remove the option to blame a test for being flaky. What should you do to prevent this? Aim to have a safe green zone by keeping your integration and unit tests in separate places.
安全的绿色测试区域应该只包含非片状、快速的测试,开发人员知道他们可以获得最新的代码版本,他们可以运行该命名空间或文件夹中的所有测试,并且测试都应该是绿色的(假设没有对生产进行任何更改)代码)。如果安全绿色区域中的某些测试未通过,开发人员更有可能感到担忧。
A safe green test area should contain only nonflaky, fast tests, where developers know that they can get the latest code version, they can run all the tests in that namespace or folder, and the tests should all be green (given no changes to production code). If some tests in the safe green zone don’t pass, a developer is much more likely to be concerned.
这种分离的另一个好处是,开发人员更有可能更频繁地运行单元测试,因为没有集成测试,运行时间会更快。有一些反馈总比没有反馈好,对吧?自动构建管道应该负责运行开发人员无法或不会在本地计算机上运行的任何“缺失”反馈测试。
An added benefit to this separation is that developers are more likely to run the unit tests more often, now that the run time is faster without the integration tests. It’s better to have some feedback than no feedback, right? The automated build pipeline should take care of running any of the “missing” feedback tests that developers can’t or won’t run on their local machines.
退出点(我也将其称为关注点)在第 1 章中进行了解释。它是工作单元的单个最终结果:返回值、系统状态更改或对第三方的调用目的。
An exit point (I’ll also refer to it as a concern) is explained in chapter 1. It’s a single end result from a unit of work: a return value, a change to system state, or a call to a third-party object.
这是一个具有两个退出点或两个关注点的函数的简单示例。它既返回一个值又触发一个传入的回调函数:
Here’s a simple example of a function that has two exit points, or two concerns. It both returns a value and triggers a passed-in callback function:
const 触发器 = (x, y, 回调) => {
回调(“我被触发了”);
返回 x + y;
};const trigger = (x, y, callback) => {
callback("I'm triggered");
return x + y;
};
We could write a test that checks both of these exit points at the same time.
Listing 7.6 Checking two exit points in the same test
描述(“触发”,()=> {
它(“有效”,()=> {
const 回调 = jest.fn();
const 结果 = 触发器(1, 2, 回调);
期望(结果).toBe(3);
Expect(callback).toHaveBeenCalledWith("我被触发了");
});
});describe("trigger", () => {
it("works", () => {
const callback = jest.fn();
const result = trigger(1, 2, callback);
expect(result).toBe(3);
expect(callback).toHaveBeenCalledWith("I'm triggered");
});
});
在测试中测试多个关注点可能会适得其反的第一个原因是您的测试名称会受到影响。我将在第 9 章中讨论可读性,但这里有一个关于命名的快速说明:命名测试对于调试和文档目的都非常重要。我花了很多时间思考测试的好名字,而且我并不羞于承认这一点。
The first reason testing more than one concern in a test can backfire is that your test name suffers. I’ll discuss readability in chapter 9, but here’s a quick note on naming: naming tests is hugely important for both debugging and documentation purposes. I spend a lot of time thinking about good names for tests, and I’m not ashamed to admit it.
命名测试可能看起来是一项简单的任务,但如果您要测试多个事物,则为测试指定一个好名称以表明正在测试的内容是很困难的。通常,您最终会得到一个非常通用的测试名称,迫使读者阅读测试代码。当您只测试一个问题时,为测试命名很容易。但等等,还有更多。
Naming a test may seem like a simple task, but if you’re testing more than one thing, giving the test a good name that indicates what’s being tested is difficult. Often you end up with a very generic test name that forces the reader to read the test code. When you test just one concern, naming the test is easy. But wait, there’s more.
更令人不安的是,在大多数单元测试框架中,失败的断言会引发测试框架运行程序捕获的特殊类型的异常。当测试框架捕获该异常时,就意味着测试失败。大多数语言中的大多数异常在设计上都不会让代码继续执行。所以如果这条线,
More disturbingly, in most unit test frameworks, a failed assert throws a special type of exception that’s caught by the test framework runner. When the test framework catches that exception, it means the test has failed. Most exceptions in most languages, by design, don’t let the code continue. So if this line,
期望(结果).toBe(3);
expect(result).toBe(3);
fails the assert, this line will not execute at all:
Expect(callback).toHaveBeenCalledWith("我被触发了");expect(callback).toHaveBeenCalledWith("I'm triggered");
测试方法在抛出异常的同一行退出。这些断言中的每一个都可以而且应该被视为不同的需求,并且它们也可以(并且在这种情况下很可能应该)一个接一个地单独且增量地实现。
The test method exits on the same line where the exception is thrown. Each of these asserts can and should be considered different requirements, and they can also, and in this case likely should, be implemented separately and incrementally, one after the other.
将断言失败视为疾病的症状。您发现的症状越多,疾病就越容易诊断。失败后,后续断言不会执行,并且您将错过其他可能的症状,这些症状可以提供有价值的数据(症状),帮助您缩小焦点并发现根本问题。在单个单元测试中检查多个问题会增加复杂性,但价值不大。您应该在单独的、独立的单元测试中运行额外的关注点检查,以便您可以看到真正失败的地方。
Consider assert failures as symptoms of a disease. The more symptoms you can find, the easier the disease will be to diagnose. After a failure, subsequent asserts aren’t executed, and you’ll miss seeing other possible symptoms that could provide valuable data (symptoms) that would help you narrow your focus and discover the underlying problem. Checking multiple concerns in a single unit test adds complexity with little value. You should run additional concern checks in separate, self-contained unit tests so that you can see what really fails.
Let’s break it up into two separate tests.
Listing 7.7 Checking the two exit points in separate tests
描述(“触发”,()=> {
it("触发给定的回调", () => {
const 回调 = jest.fn();
触发(1, 2, 回调);
Expect(callback).toHaveBeenCalledWith("我被触发了");
});
it("对给定值求和", () => {
const 结果 = 触发器(1, 2, jest.fn());
期望(结果).toBe(3);
});
});describe("trigger", () => {
it("triggers a given callback", () => {
const callback = jest.fn();
trigger(1, 2, callback);
expect(callback).toHaveBeenCalledWith("I'm triggered");
});
it("sums up given values", () => {
const result = trigger(1, 2, jest.fn());
expect(result).toBe(3);
});
});
现在我们可以清楚地分离这些关注点,并且每个关注点都可以单独失败。
Now we can clearly separate the concerns, and each one can fail separately.
有时,在同一个测试中断言多个事物是完全可以的,只要它们不是多个关注点即可。以以下函数及其相关测试为例。makePerson旨在构建person具有某些属性的新对象。
Sometimes it’s perfectly okay to assert multiple things in the same test, as long as they are not multiple concerns. Take the following function and its associated test as an example. makePerson is designed to build a new person object with some properties.
Listing 7.8 Using multiple asserts to verify a single exit point
const makePerson = (x, y) => {
返回 {
姓名:x,
年龄: 岁,
类型:“人”,
};
};
描述(“makePerson”,()=> {
it("根据传入的值创建人", () => {
const 结果 = makePerson("姓名", 1);
期望(结果.名称).toBe(“名称”);
期望(结果.年龄).toBe(1);
});
});const makePerson = (x, y) => {
return {
name: x,
age: y,
type: "person",
};
};
describe("makePerson", () => {
it("creates person given passed in values", () => {
const result = makePerson("name", 1);
expect(result.name).toBe("name");
expect(result.age).toBe(1);
});
});
在我们的测试中,我们同时断言姓名和年龄,因为它们是同一关注点(构建对象person)的一部分。如果第一个断言失败,我们可能不关心第二个断言,因为在构建对象时可能会出现严重错误。
In our test, we are asserting on both name and age together, because they are part of the same concern (building the person object). If the first assert fails, we likely don’t care about the second assert because something might have gone terribly wrong while building the object in the first place.
提示这里有一个测试分解提示:如果第一个断言失败,你还关心下一个断言的结果是什么吗?如果这样做,您可能应该将测试分成两个测试。
Tip Here’s a test break-up hint: If the first assert fails, do you still care what the result of the next assert is? If you do, you should probably separate the test into two tests.
如果测试使用当前日期和时间作为其执行或断言的一部分,那么我们可以声明每次测试运行时都是不同的测试。对于使用随机数、机器名称或任何依赖于从测试环境外部获取当前值的内容的测试也可以这样说。其结果很可能不一致,这意味着结果可能不稳定。对于我们作为开发人员来说,不稳定的测试会降低我们对测试失败结果的信任(我将在下一节中讨论)。
If a test is using the current date and time as part of its execution or assertions, then we can claim that every time the test runs, it’s a different test. The same can be said of tests that use random numbers, machine names, or anything that depends on grabbing a current value from outside the test’s environment. There’s a big chance its results won’t be consistent, and that means they can be flaky. For us, as developers, flaky tests reduce our trust in the failed results of the test (as I’ll discuss in the next section).
动态生成值的另一个巨大的潜在问题是,如果我们提前不知道系统的输入可能是什么,我们还必须计算系统的预期输出,这可能会导致错误的测试,该测试取决于关于重复生产逻辑,如第 7.3 节所述。
Another huge potential issue with dynamically generated values is that if we don’t know ahead of time what the input into the system might be, we also have to compute the expected output of the system, and that can lead to a buggy test that depends on repeating production logic, as mentioned in section 7.3.
我不确定是谁提出了“片状测试”这个术语,但它确实符合要求。它用于描述在不更改代码的情况下返回不一致结果的测试。这种情况可能经常发生或很少发生,但它确实发生了。
I’m not sure who came up with the term flaky tests, but it does fit the bill. It’s used to describe tests that, given no changes to the code, return inconsistent results. This might happen frequently or very rarely, but it does happen.
图 7.1 说明了片状现象的来源。该数字基于测试所具有的实际依赖关系的数量。另一种思考方式是测试有多少活动部件。对于本书,我们主要关注该图的底部三分之一:单元和组件测试。然而,我想谈谈更高层次的脆弱性,这样我就可以给你一些关于研究内容的指导。
Figure 7.1 illustrates where flakiness comes from. The figure is based on the number of real dependencies the tests have. Another way to think about this is how many moving parts the tests have. For this book, we’re mostly concerning ourselves with the bottom third of this diagram: unit and component tests. However, I want to touch on the higher-level flakiness so I can give you some pointers on what to research.
图 7.1 测试级别越高,它们使用的真实依赖关系就越多,这让我们对整个系统的正确性充满信心,但会导致更多的不稳定。
Figure 7.1 The higher the level of the tests, the more real dependencies they use, which gives us confidence in the overall system correctness but results in more flakiness.
在最低级别,我们的测试可以完全控制它们的所有依赖项,因此没有移动部件,要么是因为它们是伪造的,要么是因为它们纯粹在内存中运行并且可以配置。我们在第 3 章和第 4 章中做到了这一点。代码中的执行路径是完全确定的,因为所有初始状态和各种依赖项的预期返回值都已预先确定。代码路径几乎是静态的——如果它返回错误的预期结果,那么生产代码的执行路径或逻辑中可能发生了一些重要的变化。
At the lowest level, our tests have full control over all of their dependencies and therefore have no moving parts, either because they’re faking them or because they run purely in memory and can be configured. We did this in chapters 3 and 4. Execution paths in the code are fully deterministic because all the initial states and expected return values from various dependencies have been predetermined. The code path is almost static—if it returns the wrong expected result, then something important might have changed in the production code’s execution path or logic.
随着级别的提升,我们的测试会放弃越来越多的桩和模拟,并开始使用越来越多的真实依赖项,例如数据库、网络、配置等。反过来,这意味着我们无法控制更多的移动部分,并且可能会改变我们的执行路径,返回意外的值,或者根本无法执行。
As we go up the levels, our tests shed more and more stubs and mocks and start using more and more real dependencies, such as databases, networks, configuration, and more. This, in turn, means more moving parts that we have less control over and that might change our execution path, return unexpected values, or fail to execute at all.
在最高级别,不存在虚假依赖项。我们的测试所依赖的一切都是真实的,包括任何第三方服务、安全和网络层以及配置。这些类型的测试通常要求我们设置一个尽可能接近生产场景的环境,如果它们不能在生产环境上正确运行的话。
At the highest level, there are no fake dependencies. Everything our tests rely on is real, including any third-party services, security and network layers, and configuration. These types of tests usually require us to set up an environment that is as close to a production scenario as possible, if they’re not running right on the production environments.
测试图中的位置越高,我们对代码工作的信心就越高,除非我们不相信测试结果。不幸的是,我们在图表中走得越高,我们的测试就越有可能因为涉及到的移动部件而变得不稳定。
The higher up we go in the test diagram, we should get higher confidence that our code works, unless we don’t trust the tests’ results. Unfortunately, the higher up we go in the diagram, the more chances there are for our tests to become flaky because of how many moving parts are involved.
我们可能会假设最低级别的测试不应该有任何片状问题,因为不应该有任何导致片状的移动部件。这在理论上是正确的,但实际上人们仍然设法在较低级别的测试中添加移动部件:使用当前日期和时间、机器名称、网络、文件系统等可能会导致测试不稳定。
We might assume that tests at the lowest level shouldn’t have any flakiness issues because there shouldn’t be any moving parts that cause flakiness. That’s theoretically true, but in reality people still manage to add moving parts in lower-level tests: using the current date and time, the machine name, the network, the filesystem, and more can cause a test to be flaky.
A test fails sometimes without us touching production code. For example:
A test fails when various external conditions fail, such as network or database availability, other APIs not being available, environment configuration, and more.
更糟糕的是,测试使用的每个依赖项(网络、文件系统、线程等)通常都会增加测试运行的时间。调用网络和数据库需要时间。等待线程完成、读取配置和等待异步任务也是如此。
To add to that salad of pain, each dependency the test uses (network, filesystem, threads, etc.) usually adds time to the test run. Calls to the network and the database take time. The same goes for waiting for threads to finish, reading configurations, and waiting for asynchronous tasks.
找出测试失败的原因也需要更长的时间。调试测试或阅读大量日志非常耗时,并且会慢慢地将您的灵魂耗尽到“是时候更新我的简历”的深渊了。
It also takes longer to figure out why a test is failing. Debugging a test or reading through huge amounts of logs is heartbreakingly time consuming and will drain your soul slowly into the abyss of “time to update my resume” land.
重要的是要认识到不稳定的测试对于组织来说可能代价高昂。您应该将零不稳定测试作为长期目标。以下是一些降低处理片状测试相关成本的方法:
It’s important to realize that flaky tests can be costly to an organization. You should aim to have zero flaky tests as a long-term goal. Here are some ways to reduce the costs associated with handling flaky tests:
定义——就“片状”对您的组织意味着什么达成一致。例如,在不更改任何生产代码的情况下运行测试套件 10 次,并计算结果不一致的所有测试(即未全部 10 次失败或未全部 10 次通过的测试)。
Define—Agree on what “flaky” means to your organization. For example, run your test suite 10 times without any production code changes, and count all the tests that were not consistent in their results (i.e., ones that did not fail all 10 times or did not pass all 10 times).
将任何被认为不稳定的测试放在可以单独运行的测试的特殊类别或文件夹中。我建议从常规交付版本中删除所有不稳定的测试,这样它们就不会产生噪音,并暂时将它们隔离在自己的小管道中。然后,检查每个不稳定的测试并玩我最喜欢的不稳定游戏“修复、转换或杀死”:
修复——如果可能的话,通过控制其依赖关系来使测试不不稳定。例如,如果需要数据库中的数据,则将数据插入数据库作为测试的一部分。
转换— 通过删除和控制一个或多个依赖项,将测试转换为较低级别的测试,从而消除不稳定性。例如,使用桩模拟网络端点,而不是使用真实的网络端点。
Kill——认真考虑测试带来的价值是否足以继续运行它并支付它所产生的维护成本。有时,旧的片状测试最好死掉并埋葬。有时它们已经被更新、更好的测试覆盖,而旧的测试是我们可以摆脱的纯粹的技术债务。可悲的是,许多工程经理不愿意删除这些旧的测试,因为沉没成本谬误——投入了太多的精力,删除它们是一种浪费。然而,此时,保留测试的成本可能比删除测试的成本更高,因此我建议对于许多不稳定的测试认真考虑此选项。
Place any test deemed flaky in a special category or folder of tests that can be run separately. I recommend removing all flaky tests from the regular delivery build so they do not create noise, and quarantining them in their own little pipeline temporarily. Then, go over each of the flaky tests and play my favorite flaky game, “fix, convert, or kill”:
Fix—Make the test not flaky by controlling its dependencies, if possible. For example, if it requires data in the database, insert the data into the database as part of the test.
Convert—Remove flakiness by converting the test into a lower-level test by removing and controlling one or more of its dependencies. For example, simulate a network endpoint with a stub instead of using a real one.
Kill—Seriously consider whether the value the test brings is enough to continue to run it and pay the maintenance costs it creates. Sometimes old flaky tests are better off dead and buried. Sometimes they are already covered by newer, better tests, and the old tests are pure technical debt that we can get rid of. Sadly, many engineering managers are reluctant to remove these old tests because of the sunken cost fallacy—there was so much effort put into them that it would be a waste to delete them. However, at this point, it might cost you more to keep the test than to delete it, so I recommend seriously considering this option for many of your flaky tests.
如果您有兴趣防止高级测试中的不稳定,那么最好的选择是确保您的测试在任何部署后都可以在任何环境中重复。这可能涉及以下内容:
If you’re interested in preventing flakiness in higher-level tests, your best bet is to make sure that your tests are repeatable on any environment after any deployment. That could involve the following:
Roll back any changes your tests have made to external shared resources.
通过确保您能够随意重新创建外部系统和依赖项(在互联网上搜索“基础设施即代码”)、创建您可以控制的虚拟系统或在它们上创建特殊的测试帐户并祈祷,获得对外部系统和依赖项的一定控制确保他们保持安全。
Gain some control over external systems and dependencies by ensuring you have the ability to recreate them at will (do an internet search on “infrastructure as code”), creating dummies of them that you can control, or creating special test accounts on them and pray that they stay safe.
最后一点,在使用其他公司管理的外部系统时,控制外部依赖关系可能很困难或不可能。当这是真的时,值得考虑以下选项:
On this last point, controlling external dependencies can be difficult or impossible when using external systems managed by other companies. When that’s true, it’s worth considering these options:
Remove some of the higher-level tests if some low-level tests already cover those scenarios.
Convert some of the higher-level tests to a set of lower-level tests.
If you’re writing new tests, consider a pipeline-friendly testing strategy with test recipes (such as the one I’ll explain in chapter 10).
如果您在测试失败时不信任它,您可能会忽略真正的错误,如果您在测试通过时不信任它,您最终将进行大量手动调试和测试。这两种结果都应该通过良好的测试来减少,但如果我们不减少它们,并且我们花了所有时间编写我们不信任的测试,那么首先编写它们的意义何在?
If you don’t trust a test when it’s failing, you might ignore a real bug, and if you don’t trust a test when it’s passing, you’ll end up doing lots of manual debugging and testing. Both of these outcomes are supposed to be reduced by having good tests, but if we don’t reduce them, and we spend all this time writing tests that we don’t trust, what’s the point in writing them in the first place?
测试可能会因多种原因而失败:生产代码中发现真正的错误、测试中导致错误失败的错误、由于功能更改而导致测试过时、测试与另一个测试冲突或测试不稳定。只有第一个原因是有效的。所有其他人都告诉我们测试不应该被信任。
Tests might fail for multiple reasons: a real bug found in production code, a bug in the test resulting in a false failure, a test being out of date due to a change in functionality, a test conflicting with another test, or test flakiness. Only the first reason is a valid one. All the others tell us the test shouldn’t be trusted.
避免测试中的复杂性,例如创建动态期望值或从底层生产代码复制逻辑。这种复杂性增加了在测试中引入错误的机会以及理解它们所需的时间。
Avoid complexity in tests, such as creating dynamic expected values or duplicating logic from the underlying production code. Such complexity increases the chances of introducing bugs in tests and the time it takes to understand them.
如果一个测试没有任何断言,你无法理解它在做什么,它与片状测试一起运行(即使这个测试本身不是片状的),它验证多个退出点,或者它不断变化,它可以'不能完全信任。
If a test doesn’t have any asserts, you can’t understand what's it’s doing, it runs alongside flaky tests (even if this test itself isn’t flaky), it verifies multiple exit points, or it keeps changing, it can’t be fully trusted.
Flaky 测试是不可预测地失败的测试。测试的级别越高,它使用的真实依赖关系就越多,这让我们对整个系统的正确性充满信心,但会导致更多的不稳定。为了更好地识别不稳定的测试,请将它们放在可以单独运行的特殊类别或文件夹中。
Flaky tests are tests that fail unpredictably. The higher the level of the test, the more real dependencies it uses, which gives us confidence in the overall system’s correctness but results in more flakiness. To better identify flaky tests, put them in a special category or folder that can be run separately.
To reduce test flakiness, either fix the tests, convert flaky higher-level tests into less flaky lower-level ones, or delete them.
测试可以让我们更快地开发,除非它们因为所有需要的改变而让我们进展得更慢。如果我们能够在更改生产代码时避免更改现有测试,我们就可以开始希望我们的测试能够帮助而不是损害我们的底线。在本章中,我们将重点关注测试的可维护性。
Tests can enable us to develop faster, unless they make us go slower due to all the changes needed. If we can avoid changing existing tests when we change production code, we can start to hope that our tests are helping rather than hurting our bottom line. In this chapter, we’ll focus on the maintainability of tests.
无法维护的测试可能会破坏项目进度,并且当项目进度更加紧迫时,测试通常会被搁置。开发人员将停止维护和修复那些需要很长时间才能更改或由于非常小的生产代码更改而需要经常更改的测试。
Unmaintainable tests can ruin project schedules and are often set aside when the project is put on a more aggressive schedule. Developers will simply stop maintaining and fixing tests that take too long to change or that need to change often as the result of very minor production code changes.
如果可维护性是衡量我们被迫更改测试的频率的标准,那么我们希望尽量减少发生这种情况的次数。如果我们想找出根本原因,这迫使我们提出这些问题:
If maintainability is a measure of how often we are forced to change tests, we’d like to minimize the number of times that happens. This forces us to ask these questions if we ever want to get down to the root causes:
When do we notice that a test fails and therefore might require a change?
When do we choose to change a test even if we are not forced to?
本章介绍了一系列与可维护性相关的实践,您可以在进行测试评审时使用它们。
This chapter presents a series of practices related to maintainability that you can use when doing test reviews.
失败的测试通常是可维护性出现潜在问题的第一个迹象。当然,我们可以在生产代码中发现真正的错误,但如果情况并非如此,测试失败还有什么其他原因呢?我将把真正的故障称为真正的故障,将由于在底层生产代码中发现错误以外的原因发生的故障称为假故障。
A failing test is usually the first sign of potential trouble for maintainability. Of course, we could have found a real bug in production code, but when that’s not the case, what other reasons do tests have to fail? I’ll refer to genuine failures as true failures, and failures that happen for reasons other than finding a bug in the underlying production code as false failures.
如果我们想要衡量测试的可维护性,我们可以首先衡量随着时间的推移错误测试失败的数量以及每次失败的原因。我们已经在第 7 章中讨论了这样一个原因:当测试包含错误时。现在让我们讨论错误失败的其他可能原因。
If we wanted to measure test maintainability, we could start by measuring the number of false test failures, and the reason for each failure, over time. We already discussed one such reason in chapter 7: when a test contains a bug. Let’s now discuss other possible reasons for false failures.
当生产代码引入与一个或多个现有测试直接冲突的新功能时,可能会出现冲突。测试可能不会发现错误,而是会发现冲突或新的需求。还可能有一个针对生产代码应如何工作的新期望的通过测试。
A conflict may arise when the production code introduces a new feature that’s in direct conflict with one or more existing tests. Instead of the test discovering a bug, it may discover conflicting or new requirements. There might also be a passing test that targets the new expectation for how the production code should work.
要么现有的失败测试不再相关,要么新的要求是错误的。假设要求是正确的,您可以继续删除不再相关的测试。
Either the existing failing test is no longer relevant, or the new requirement is wrong. Assuming that the requirement is correct, you can probably go ahead and delete the no-longer-relevant test.
请注意,“删除测试”规则有一个常见的例外:当您使用功能切换时。当我们讨论测试策略时,我们将在第 10 章中讨论功能切换。
Note that there’s a common exception to the “remove the test” rule: when you’re working with feature toggles. We’ll touch on feature toggles in chapter 10 when we discuss testing strategies.
如果被测生产代码发生变化,导致被测试的函数或对象现在需要以不同的方式使用,即使它可能仍然具有相同的功能,测试也可能会失败。这种错误的失败属于“让我们尽可能避免这种情况”的情况。
A test can fail if the production code under test changes so that a function or object being tested now needs to be used differently, even though it may still have the same functionality. Such false failures fall in the bucket of “let’s avoid this as much as possible.”
考虑PasswordVerifier清单 8.1 中的类,它需要两个构造函数参数:
Consider the PasswordVerifier class in listing 8.1, which requires two constructor parameters:
Listing 8.1 A Password Verifier with two constructor parameters
导出类密码验证器{
...
构造函数(规则:((输入)=>布尔值)[],记录器:ILogger){
this._rules = 规则;
this._logger = 记录器;
}
...
}export class PasswordVerifier {
...
constructor(rules: ((input) => boolean)[], logger: ILogger) {
this._rules = rules;
this._logger = logger;
}
...
}
We could write a couple of tests like the following.
Listing 8.2 Tests without factory functions
描述(“密码验证者1”,()=> {
it("零规则通过", () => {
const verifier = new PasswordVerifier([], { info: jest.fn() }); ❶
const result = verifier.verify("任意输入");
期望(结果)。toBe(真);
});
it("因单个失败规则而失败", () => {
const failedRule = (输入) => false;
常量验证器 =
新的PasswordVerifier([failingRule], { info: jest.fn() }); ❶
const result = verifier.verify("任意输入");
期望(结果).toBe(假);
});
});describe("password verifier 1", () => {
it("passes with zero rules", () => {
const verifier = new PasswordVerifier([], { info: jest.fn() }); ❶
const result = verifier.verify("any input");
expect(result).toBe(true);
});
it("fails with single failing rule", () => {
const failingRule = (input) => false;
const verifier =
new PasswordVerifier([failingRule], { info: jest.fn() }); ❶
const result = verifier.verify("any input");
expect(result).toBe(false);
});
});
❶ Test using the code’s existing API
如果我们从可维护性的角度来看这些测试,我们将来可能需要做出一些潜在的改变。
If we look at these tests from a maintainability point of view, there are several potential changes we will likely need to make in the future.
Code usually lives for a long time
考虑到您正在编写的代码将在代码库中保留至少 4-6 年,有时甚至十年。在那段时间里,设计发生PasswordVerifier变化的可能性有多大?即使是简单的事情,比如构造函数接受更多参数,或者参数类型发生变化,在更长的时间范围内也变得更有可能。
Consider that the code you’re writing will live in the codebase for at least 4-6 years and sometimes a decade. Over that time, what is the likelihood that the design of PasswordVerifier will change? Even simple things, like the constructor accepting more parameters, or the parameter types changing, become more likely over a longer timeframe.
Let’s list a few changes that could happen to our Password Verifier in the future:
We may add or remove a parameter in the constructor for PasswordVerifier.
One of the parameters for PasswordVerifier may change to a different type.
The number of ILogger functions or their signatures may change over time.
The usage pattern changes so we don’t need to instantiate a new PasswordVerifier, but just use the functions in it directly.
如果发生这些情况,我们需要更改多少测试?现在我们需要更改所有实例化的测试PasswordVerifier。我们可以阻止其中一些更改的需要吗?
If any of these things happen, how many tests would we need to change? Right now we’d need to change all the tests that instantiate PasswordVerifier. Could we prevent the need for some of these changes?
让我们假设未来已经到来,我们的担心已经成真——有人改变了生产代码的 API。假设构造函数签名已更改为 useIComplicatedLogger而不是ILogger,如下所示。
Let’s pretend the future is here and our fears have come true—someone changed the production code’s API. Let’s say the constructor signature has changed to use IComplicatedLogger instead of ILogger, as follows.
Listing 8.3 A breaking change in a constructor
导出类密码验证器2 {
私人_规则:((输入:字符串)=>布尔)[];
私人_logger:IComplicatedLogger;
构造函数(规则:((输入)=>布尔值)[],
记录器:IComplicatedLogger ) {
this._rules = 规则;
this._logger = 记录器;
}
...
}export class PasswordVerifier2 {
private _rules: ((input: string) => boolean)[];
private _logger: IComplicatedLogger;
constructor(rules: ((input) => boolean)[],
logger: IComplicatedLogger) {
this._rules = rules;
this._logger = logger;
}
...
}
就目前情况而言,我们必须更改任何直接实例化的测试PasswordVerifier。
As it stands, we would have to change any test that directly instantiates PasswordVerifier.
Factory functions decouple creation of object under test
将来避免这种痛苦的一个简单方法是解耦或抽象出被测代码的创建,以便仅需要在集中位置处理对构造函数的更改。其唯一目的是创建和预配置对象实例的函数通常称为工厂函数或方法。更高级的版本(我们不会在这里介绍)是对象母模式。
A simple way to avoid this pain in the future is to decouple or abstract away the creation of the code under test so that the changes to the constructor only need to be dealt with in a centralized location. A function whose sole purpose is to create and preconfigure an instance of an object is usually called a factory function or method. A more advanced version of this (which we won’t cover here) is the Object Mother pattern.
工厂功能可以帮助我们缓解这个问题。接下来的两个清单显示了我们如何在签名更改之前最初编写测试,以及在这种情况下如何轻松适应签名更改。在清单 8.4 中, 的创建PasswordVerifier已被提取到其自己的集中工厂函数中。我对它做了同样的事情fakeLogger——它现在也是使用它自己的单独的工厂函数创建的。如果将来发生我们之前列出的任何更改,我们只需要更改我们的工厂功能即可;通常不需要触及测试。
Factory functions can help us mitigate this issue. The next two listings show how we could have initially written the tests before the signature change, and how we could easily adapt to the signature change in that case. In listing 8.4, the creation of PasswordVerifier has been extracted into its own centralized factory function. I’ve done the same for the fakeLogger—it’s now also created using its own separate factory function. If any of the changes we listed before happens in the future, we’ll only need to change our factory functions; the tests will usually not need to be touched.
Listing 8.4 Refactoring to factory functions
描述(“密码验证者1”,()=> {
const makeFakeLogger = () => {
返回 { 信息:jest.fn() }; ❶
};
const makePasswordVerifier = (
规则:((输入) => 布尔值)[],
fakeLogger: ILogger = makeFakeLogger()) => {
返回新的PasswordVerifier(规则,fakeLogger); ❷
};
it("零规则通过", () => {
const 验证器 = makePasswordVerifier([]); ❸
const result = verifier.verify("任意输入");
期望(结果)。toBe(真);
});describe("password verifier 1", () => {
const makeFakeLogger = () => {
return { info: jest.fn() }; ❶
};
const makePasswordVerifier = (
rules: ((input) => boolean)[],
fakeLogger: ILogger = makeFakeLogger()) => {
return new PasswordVerifier(rules, fakeLogger); ❷
};
it("passes with zero rules", () => {
const verifier = makePasswordVerifier([]); ❸
const result = verifier.verify("any input");
expect(result).toBe(true);
});
❶ A centralized point for creating a fakeLogger
❷ A centralized point for creating a PasswordVerifier
❸ Using the factory function to create PasswordVerifier
在下面的清单中,我根据签名更改重构了测试。请注意,更改不涉及更改测试,而仅涉及工厂功能。这就是我在实际项目中可以接受的可管理变更类型。
In the following listing, I’ve refactored the tests based on the signature change. Notice that the change doesn’t involve changing the tests, but only the factory functions. That’s the type of manageable change I can live with in a real project.
Listing 8.5 Refactoring factory methods to fit a new signature
描述(“密码验证器(ctor更改)”,()=> {
const makeFakeLogger = () => {
return Substitute.for<IComplicatedLogger>();
};
const makePasswordVerifier = (
规则:((输入) => 布尔值)[],
fakeLogger: IComplicatedLogger = makeFakeLogger()) => {
返回新的PasswordVerifier2(规则,fakeLogger);
};
// 测试保持不变
});describe("password verifier (ctor change)", () => {
const makeFakeLogger = () => {
return Substitute.for<IComplicatedLogger>();
};
const makePasswordVerifier = (
rules: ((input) => boolean)[],
fakeLogger: IComplicatedLogger = makeFakeLogger()) => {
return new PasswordVerifier2(rules, fakeLogger);
};
// the tests remain the same
});
缺乏测试隔离是测试阻塞的一个重要原因——我在咨询和进行单元测试时已经看到了这一点。您应该记住的基本概念是,测试应该始终在自己的小世界中运行,与其他测试隔离,即使它们验证相同的功能。
A lack of test isolation is a huge cause of test blockage—I’ve seen this while consulting and working on unit tests. The basic concept you should keep in mind is that a test should always run in its own little world, isolated from other tests even if they verify the same functionality.
当测试没有很好地隔离时,它们可能会互相踩到对方的脚趾,让你后悔决定尝试单元测试并承诺自己永远不会再这样做。我见过这种情况发生。开发人员不会费心在测试中寻找问题,因此当出现问题时,可能需要花费大量时间才能找出问题所在。最简单的症状就是我所说的“测试顺序受限”。
When tests aren’t isolated well, they can step on each other’s toes, making you regret deciding to try unit testing and promising yourself never again. I’ve seen this happen. Developers don’t bother looking for problems in the tests, so when there’s a problem, it can take a lot of time to find out what’s wrong. The easiest symptom is what I call “constrained test order.”
当测试假设先前的测试首先执行或没有首先执行时,就会出现受约束的测试顺序,因为它依赖于由其他测试设置或重置的某些共享状态。例如,如果一个测试更改了内存中的共享变量或数据库等某些外部资源,而另一个测试在第一个测试执行后依赖于该变量的值,则测试之间存在基于顺序的依赖关系。
A constrained test order happens when a test assumes that a previous test executed first, or did not execute first, because it relies on some shared state that is set up or reset by the other test. For example, if one test changes a shared variable in memory or some external resource like a database, and another test depends on that variable’s value after the first tests’ execution, we have a dependency between the tests based on order.
再加上大多数测试运行者不(也不会,也许不应该!)保证测试将按特定顺序运行的事实。这意味着,如果您今天运行所有测试,并在一周后使用新版本的测试运行器运行所有测试,则测试可能不会按照与以前相同的顺序运行。
Couple that with the fact that most test runners don’t (and won’t, and maybe shouldn’t!) guarantee that tests will run in a specific order. This means that if you ran all your tests today, and all your tests a week later with a new version of the test runner, the tests might not run in the same order as before.
Figure 8.1 A shared UserCache instance
为了说明这个问题,让我们看一个简单的场景。图 8.1 显示了SpecialApp使用UserCache对象的对象。用户缓存保存单个实例(单例),该实例作为应用程序的缓存机制共享,顺便说一句,也用于测试。清单 8.6 显示了 的实现SpecialApp、用户缓存和IUserDetails接口。
To illustrate the problem, let’s look at a simple scenario. Figure 8.1 shows a SpecialApp object that uses a UserCache object. The user cache holds a single instance (a singleton) that is shared as a caching mechanism for the application, and, incidentally, also for the tests. Listing 8.6 shows the implementation of SpecialApp, the user cache, and the IUserDetails interface.
Listing 8.6 A shared user cache and associated interfaces
导出接口 IUserDetails {
键:字符串;
密码:字符串;
}
导出接口 IUserCache {
addUser(用户:IUserDetails): void;
getUser(键:字符串);
重置():无效;
}
导出类 UserCache实现 IUserCache {
用户:对象= {};
addUser(用户:IUserDetails): void {
if (this.users[user.key] !== undefined) {
throw new Error("用户已经存在");
}
this.user[用户.key] = 用户;
}
获取用户(键:字符串){
返回 this.user[key];
}
重置():无效{
this.users = {};
}
}
让_cache:IUserCache;
导出函数 getUserCache() {
如果(_cache ===未定义){
_cache = 新的UserCache();
}
返回_缓存;
}export interface IUserDetails {
key: string;
password: string;
}
export interface IUserCache {
addUser(user: IUserDetails): void;
getUser(key: string);
reset(): void;
}
export class UserCache implements IUserCache {
users: object = {};
addUser(user: IUserDetails): void {
if (this.users[user.key] !== undefined) {
throw new Error("user already exists");
}
this.users[user.key] = user;
}
getUser(key: string) {
return this.users[key];
}
reset(): void {
this.users = {};
}
}
let _cache: IUserCache;
export function getUserCache() {
if (_cache === undefined) {
_cache = new UserCache();
}
return _cache;
}
The following listing shows the SpecialApp implementation.
Listing 8.7 The SpecialApp implementation
导出类 SpecialApp {
登录用户(键:字符串,密码:字符串):布尔值{
常量缓存:IUserCache = getUserCache();
const findUser: IUserDetails = cache.getUser(key);
if (foundUser?.password === pass) {
返回真;
}
返回假;
}
}export class SpecialApp {
loginUser(key: string, pass: string): boolean {
const cache: IUserCache = getUserCache();
const foundUser: IUserDetails = cache.getUser(key);
if (foundUser?.password === pass) {
return true;
}
return false;
}
}
这是本示例的一个简单实现,因此不必担心SpecialApp太多。让我们看看测试。
This is a simplistic implementation for this example, so don’t worry about SpecialApp too much. Let’s look at the tests.
Listing 8.8 Tests that need to run in a specific order
描述(“测试依赖性”,()=> {
描述(“登录用户与登录用户”,()=> {
test("无用户,登录失败", () => {
const app = new SpecialApp();
const 结果 = app.loginUser("a", "abc"); ❶
期望(结果).toBe(假); ❶
});
test("每个用户只能缓存一次", () => {
getUserCache().addUser({ ❷
键:“a”,
密码:“abc”,
});
期望(()=>
getUserCache().addUser({
键:“a”,
密码:“abc”,
})
).toThrowError("已经存在");
});
test("用户存在,登录成功", () => {
const app = new SpecialApp();
const 结果 = app.loginUser("a", "abc"); ❸
Expect(结果).toBe(true); ❸
});
});
});describe("Test Dependence", () => {
describe("loginUser with loggedInUser", () => {
test("no user, login fails", () => {
const app = new SpecialApp();
const result = app.loginUser("a", "abc"); ❶
expect(result).toBe(false); ❶
});
test("can only cache each user once", () => {
getUserCache().addUser({ ❷
key: "a",
password: "abc",
});
expect(() =>
getUserCache().addUser({
key: "a",
password: "abc",
})
).toThrowError("already exists");
});
test("user exists, login succeeds", () => {
const app = new SpecialApp();
const result = app.loginUser("a", "abc"); ❸
expect(result).toBe(true); ❸
});
});
});
❶ Requires the user cache to be empty
❸ Requires the cache to contain the user
请注意,第一个和第三个测试都依赖于第二个测试。第一个测试要求第二个测试尚未执行,因为它需要用户缓存为空。另一方面,第三个测试依赖于第二个测试来用预期用户填充缓存。如果我们仅使用 Jest 的关键字运行第三个测试test.only,则测试将失败:
Notice that the first and third tests both rely on the second test. The first test requires that the second test has not executed yet, because it needs the user cache to be empty. On the other hand, the third test relies on the second test to fill up the cache with the expected user. If we run only the third test using Jest’s test.only keyword, the test would fail:
test.only ("用户存在,登录成功", () => {
const app = new SpecialApp();
const 结果 = app.loginUser("a", "abc");
期望(结果)。toBe(真);
});test.only("user exists, login succeeds", () => {
const app = new SpecialApp();
const result = app.loginUser("a", "abc");
expect(result).toBe(true);
});
当我们尝试重用部分测试而不提取辅助函数时,通常会发生这种反模式。我们最终期望首先运行不同的测试,从而使我们免于进行一些设置。这会起作用,直到不起作用为止。
This antipattern usually happens when we try to reuse parts of tests without extracting helper functions. We end up expecting a different test to run first, saving us from doing some of the setup. This works, until it doesn’t.
We can refactor this in a few steps:
The following listing shows how we could refactor the tests to avoid this problem.
Listing 8.9 Refactoring tests to remove order dependence
const addDefaultUser = () => ❶ getUserCache().addUser({ 键:“a”, 密码:“abc”, }); const makeSpecialApp = () => new SpecialApp(); ❷ 描述(“测试依赖 v2”,()=> { beforeEach(() => getUserCache().reset()); ❸ 描述("用户缓存", () => { ❹ test("只能添加缓存使用一次", () => { 添加默认用户(); ❺ 期望(()=> addDefaultUser()) .toThrowError("已经存在"); }); }); 描述(“登录用户与登录用户”,()=>{ ❹ test("用户存在,登录成功", () => { 添加默认用户(); ❺ const app = makeSpecialApp(); const 结果 = app.loginUser("a", "abc"); 期望(结果)。toBe(真); }); test("用户缺失,登录失败", () => { const app = makeSpecialApp(); const 结果 = app.loginUser("a", "abc"); 期望(结果).toBe(假); }); }); });
const addDefaultUser = () => ❶ getUserCache().addUser({ key: "a", password: "abc", }); const makeSpecialApp = () => new SpecialApp(); ❷ describe("Test Dependence v2", () => { beforeEach(() => getUserCache().reset()); ❸ describe("user cache", () => { ❹ test("can only add cache use once", () => { addDefaultUser(); ❺ expect(() => addDefaultUser()) .toThrowError("already exists"); }); }); describe("loginUser with loggedInUser", () => { ❹ test("user exists, login succeeds", () => { addDefaultUser(); ❺ const app = makeSpecialApp(); const result = app.loginUser("a", "abc"); expect(result).toBe(true); }); test("user missing, login fails", () => { const app = makeSpecialApp(); const result = app.loginUser("a", "abc"); expect(result).toBe(false); }); }); });
❶ Extracted user-creation helper function
❸ Resets user cache between tests
❹ New nested describe functions
❺ Calls reusable helper functions
这里发生了几件事。首先,我们提取了两个辅助函数:一个makeSpecialApp工厂函数和一个addDefaultUser可以重用的辅助函数。接下来,我们创建了一个非常重要的beforeEach函数,用于在每次测试之前重置用户缓存。每当我有这样的共享资源时,我几乎总是有一个beforeEachorafterEach函数在测试运行之前或之后将其重置为原始状态。
There are several things going on here. First, we extracted two helper functions: a makeSpecialApp factory function and an addDefaultUser helper function that we can reuse. Next, we created a very important beforeEach function that resets the user cache before each test. Whenever I have a shared resource like that, I almost always have a beforeEach or afterEach function that resets it to its original condition before or after the test runs.
第一个和第三个测试现在在它们自己的小嵌套describe结构中运行。它们还都使用makeSpecialApp工厂函数,其中一个用于addDefaultUser确保它不需要先运行任何其他测试。第二个测试也在它自己的嵌套describe函数中运行并重用该addDefaultUser函数。
The first and the third tests now run in their own little nested describe structure. They also both use the makeSpecialApp factory function, and one of them is using addDefaultUser to make sure it does not require any other test to run first. The second test also runs in its own nested describe function and reuses the addDefaultUser function.
到目前为止,我已经讨论了迫使我们做出改变的测试失败。现在让我们讨论我们选择进行的更改,以使测试随着时间的推移更容易维护。
Up until now, I’ve discussed test failures that force us to make changes. Let’s now discuss changes that we choose to make, to make tests easier to maintain over time.
本节更适用于面向对象语言以及 TypeScript。私有或受保护的方法通常是私有的,这在开发人员看来是有充分理由的。有时它是为了隐藏实现细节,以便以后可以更改实现而不改变可观察的行为。也可能是出于安全相关或 IP 相关的原因(例如混淆)。
This section applies more to object-oriented languages as well as TypeScript. Private or protected methods are usually private for a good reason in the developer’s mind. Sometimes it’s to hide implementation details, so that the implementation can change later without changing the observable behavior. It could also be for security-related or IP-related reasons (obfuscation, for example).
当您测试私有方法时,您正在针对系统内部的合同进行测试。内部契约是动态的,当您重构系统时它们可能会发生变化。当它们发生变化时,即使系统的整体功能保持不变,您的测试也可能会失败,因为某些内部工作的完成方式不同。出于测试目的,您需要关心的只是公共契约(可观察的行为)。即使可观察到的行为是正确的,测试私有方法的功能也可能会导致测试失败。
When you test a private method, you’re testing against a contract internal to the system. Internal contracts are dynamic, and they can change when you refactor the system. When they change, your test could fail because some internal work is being done differently, even though the overall functionality of the system remains the same. For testing purposes, the public contract (the observable behavior) is all you need to care about. Testing the functionality of private methods may lead to breaking tests, even though the observable behavior is correct.
可以这样想:私有方法不存在于真空中。在接下来的某个地方,必须有东西调用它,否则它永远不会被触发。通常有一个公共方法最终会调用这个私有方法,如果没有,则在调用链上总会有一个公共方法被调用。这意味着任何私有方法始终是系统中更大的工作单元或用例的一部分,该单元以公共 API 开始,以三个最终结果之一结束:返回值、状态更改或第三方调用(或全部三个)。
Think of it this way: no private method exists in a vacuum. Somewhere down the line, something has to call it, or it will never get triggered. Usually there’s a public method that ends up invoking this private one, and if not, there’s always a public method up the chain of calls that gets invoked. This means that any private method is always part of a bigger unit of work, or use case in the system, that starts out with a public API and ends with one of the three end results: return value, state change, or third-party call (or all three).
因此,如果您看到私有方法,请在系统中找到将使用该方法的公共用例。如果您仅测试私有方法并且它有效,这并不意味着系统的其余部分正确使用此私有方法或正确处理它提供的结果。您可能拥有一个内部运行良好的系统,但公共 API 中所有优秀的内部内容都被错误地使用。
So if you see a private method, find the public use case in the system that will exercise it. If you test only the private method and it works, that doesn’t mean that the rest of the system is using this private method correctly or handles the results it provides correctly. You might have a system that works perfectly on the inside, but all that nice inside stuff is used incorrectly from the public APIs.
有时,如果私有方法值得测试,那么可能值得将其公开、静态或至少内部,并针对使用它的任何代码定义公共契约。在某些情况下,如果将方法完全放在不同的类中,设计可能会更清晰。我们稍后将讨论这些方法。
Sometimes, if a private method is worth testing, it might be worth making it public, static, or at least internal, and defining a public contract against any code that uses it. In some cases, the design may be cleaner if you put the method in a different class altogether. We’ll look at those approaches in a moment.
这是否意味着代码库中最终不应该有私有方法?不会。在测试驱动设计中,您通常会针对公共方法编写测试,然后这些公共方法会被重构为调用更小的私有方法。与此同时,针对公共方法的测试不断通过。
Does this mean there should eventually be no private methods in the codebase? No. With test-driven design, you usually write tests against methods that are public, and those public methods are later refactored into calling smaller, private methods. All the while, the tests against the public methods continue to pass.
公开方法不一定是坏事。在一个更加实用的世界中,这甚至不是问题。这种做法似乎违背了我们许多人从小就遵循的面向对象原则,但情况并非总是如此。
Making a method public isn’t necessarily a bad thing. In a more functional world, it’s not even an issue. This practice may seem to go against the object-oriented principles many of us were raised on, but that’s not always the case.
考虑一下,想要测试一个方法可能意味着该方法具有已知的行为或与调用代码的契约。通过将其公开,您就将其正式化。通过将方法保持为私有,您可以告诉所有追随您的开发人员,他们可以更改该方法的实现,而不必担心使用该方法的未知代码。
Consider that wanting to test a method could mean that the method has a known behavior or contract against the calling code. By making it public, you’re making this official. By keeping the method private, you tell all the developers who come after you that they can change the implementation of the method without worrying about unknown code that uses it.
Extracting methods to new classes or modules
如果您的方法包含大量可以独立存在的逻辑,或者它在类或模块中使用仅与相关方法相关的专用状态变量,则最好将该方法提取到新类中或者在系统中具有特定角色的自己的模块。然后您可以单独测试该类。Michael Feathers 的《有效处理旧代码》(Pearson,2004 年)提供了有关此技术的一些很好的示例,而Robert Martin 的《Clean Code》(Pearson,2008 年)可以帮助您了解何时这是一个好主意。
If your method contains a lot of logic that can stand on its own, or it uses specialized state variables in the class or module that are relevant only to the method in question, it may be a good idea to extract the method into a new class or its own module with a specific role in the system. You can then test that class separately. Michael Feathers’ Working Effectively with Legacy Code (Pearson, 2004) has some good examples of this technique, and Clean Code by Robert Martin (Pearson, 2008) can help you figure out when this is a good idea.
Making stateless private methods public and static
如果您的方法是完全无状态的,有些人会选择通过使其静态(在支持此功能的语言中)来重构该方法。这使得它更易于测试,但也表明该方法是一种实用方法,具有由其名称指定的已知公共契约。
If your method is completely stateless, some people choose to refactor the method by making it static (in languages that support this feature). That makes it much more testable but also states that the method is a sort of utility method that has a known public contract specified by its name.
作为开发人员,单元测试中的重复对您的伤害与生产代码中的重复一样严重,甚至更严重。这是因为对具有重复项的代码进行任何更改都会迫使您也更改所有重复项。当您处理测试时,开发人员只是避免这种麻烦并删除或忽略测试而不是修复它们的风险更大。
Duplication in your unit tests can hurt you, as a developer, just as much as, if not more than, duplication in production code. That’s because any change in a piece of code that has duplicates will force you to change all the duplicates as well. When you’re dealing with tests, there’s more risk of the developer just avoiding this trouble and deleting or ignoring tests instead of fixing them.
DRY(不要重复自己)原则应该在测试代码中有效,就像在生产代码中一样。重复的代码意味着当您测试某一方面的更改时,需要更改更多代码。更改构造函数或更改使用类的语义可能会对具有大量重复代码的测试产生重大影响。
The DRY (don’t repeat yourself) principle should be in effect in test code just as in production code. Duplicated code means there’s more code to change when one aspect you test against changes. Changing a constructor or changing the semantics of using a class can have a major effect on tests that have a lot of duplicated code.
正如我们在本章前面的示例中所看到的,使用辅助函数可以帮助减少测试中的重复。
As we’ve seen in previous examples in this chapter, using helper functions can help to reduce duplication in tests.
警告删除重复项也可能走得太远并损害可读性。我们将在下一章中讨论可读性。
warning Removing duplication can also go too far and hurt readability. We’ll talk about that in the next chapter, on readability.
我不喜欢在每次测试之前发生一次并且通常用于删除重复的beforeEach函数(也称为设置函数)。我更喜欢使用辅助函数。设置功能太容易被滥用。开发人员倾向于将它们用于不该做的事情,结果导致测试的可读性和可维护性降低。
I’m not a fan of the beforeEach function (also called a setup function) that happens once before each test and is often used to remove duplication. I much prefer using helper functions. Setup functions are too easy to abuse. Developers tend to use them for things they weren’t meant for, and tests become less readable and less maintainable as a result.
Many developers abuse setup methods in several ways:
此外,设置方法也有限制,您可以通过使用简单的辅助方法来解决这些限制:
Also, setup methods have limitations, which you can get around by using simple helper methods:
Setup methods can only help when you need to initialize things.
设置方法并不总是删除重复项的最佳选择。删除重复并不总是与创建和初始化对象的新实例有关。有时它是关于删除断言逻辑中的重复或以特定方式调用代码。
Setup methods aren’t always the best candidates for duplication removal. Removing duplication isn’t always about creating and initializing new instances of objects. Sometimes it’s about removing duplication in assertion logic or calling out code in a specific way.
设置方法不能用作返回值的工厂方法。它们在测试执行之前运行,因此它们的工作方式必须更加通用。测试有时需要请求特定的事物或使用特定测试的参数调用共享代码(例如,检索对象并将其属性设置为特定值)。
Setup methods can’t be used as factory methods that return values. They’re run before the test executes, so they must be more generic in the way they work. Tests sometimes need to request specific things or call shared code with a parameter for the specific test (for example, retrieving an object and setting its property to a specific value).
Setup methods should only contain code that applies to all the tests in the current test class, or the method will be harder to read and understand.
我几乎完全停止使用我编写的测试的设置方法。测试代码应该是漂亮和干净的,就像生产代码一样,但是如果您的生产代码看起来很糟糕,请不要用它作为拐杖来编写不可读的测试。使用工厂和辅助方法,让世界变得更美好,让一代开发人员在 5 到 10 年内必须维护您的代码。
I’ve almost entirely stopped using setup methods for the tests I write. Test code should be nice and clean, just like production code, but if your production code looks horrible, please don’t use that as a crutch to write unreadable tests. Use factory and helper methods, and make the world a better place for the generation of developers that will have to maintain your code in 5 or 10 years.
注意:beforeEach我们在第 8.2.3 节(清单 8.9)和第 2 章中查看了从使用函数转向辅助函数的示例。
Note We looked at an example of moving from using beforeEach to helper functions in section 8.2.3 (listing 8.9) and also in chapter 2.
如果所有测试看起来都相同,则替换设置方法的另一个好选择是使用参数化测试。不同语言的不同测试框架支持参数化测试 - 如果您使用 Jest,则可以使用内置test.each或it.each函数。
Another great option for replacing setup methods, if all your tests look the same, is to use parameterized tests. Different test frameworks in different languages support parameterized tests—if you’re using Jest, you can use the built-in test.each or it.each functions.
beforeEach参数化有助于将设置逻辑移动到测试的排列部分,否则这些设置逻辑将保持重复或驻留在块中。它还有助于避免重复断言逻辑,如以下清单所示。
Parameterization helps move the setup logic that would otherwise remain duplicated or would reside in the beforeEach block to the test’s arrange section. It also helps avoid duplication of the assertion logic, as shown in the following listing.
Listing 8.10 Parameterized tests with Jest
const sum = 数字 => {
if (numbers.length > 0) {
返回parseInt(数字);
}
返回0;
};
描述('与常规测试相加',()=> {
test('总和1', () => {
const 结果 = sum('1'); ❶
期望(结果).toBe(1); ❶
});
test('总和2', () => {
const 结果 = sum('2'); ❶
期望(结果).toBe(2); ❶
});
});
描述('与参数化测试相加',()=> {
test.each([
['1', 1], ❷
['2', 2] ❷
]) ('add ,for %s , 返回该数字', (输入, 预期) => {
const 结果 = sum(输入); ❸expect
(结果).toBe(预期); ❸
}
)
});const sum = numbers => {
if (numbers.length > 0) {
return parseInt(numbers);
}
return 0;
};
describe('sum with regular tests', () => {
test('sum number 1', () => {
const result = sum('1'); ❶
expect(result).toBe(1); ❶
});
test('sum number 2', () => {
const result = sum('2'); ❶
expect(result).toBe(2); ❶
});
});
describe('sum with parameterized tests', () => {
test.each([
['1', 1], ❷
['2', 2] ❷
])('add ,for %s, returns that number', (input, expected) => {
const result = sum(input); ❸
expect(result).toBe(expected); ❸
}
)
});
❶ Duplicated setup and assertion logic
❷ Test data used for setup and assertion
❸ Setup and assertion without duplication
在第一个describe块中,我们有两个测试,它们使用不同的输入值和预期输出相互重复。在第二个describe块中,我们使用test.each提供一个数组数组,其中每个子数组列出了测试函数所需的所有值。
In the first describe block, we have two tests that repeat each other with different input values and expected outputs. In the second describe block, we’re using test.each to provide an array of arrays, where each subarray lists all the values needed for the test function.
参数化测试可以帮助减少测试之间的大量重复,但我们应该小心,仅在重复完全相同的场景并且仅更改输入和输出的情况下才使用此技术。
Parameterized tests can help reduce a lot of duplication between tests, but we should be careful to only use this technique in cases where we repeat the exact same scenario and only change the input and output.
过度指定的测试包含有关被测特定单元(生产代码)应如何实现其内部行为的假设,而不是仅检查可观察的行为(退出点)是否正确。
An overspecified test is one that contains assumptions about how a specific unit under test (production code) should implement its internal behavior, instead of only checking that the observable behavior (exit points) is correct.
Here are ways unit tests are often overspecified:
A test asserts purely internal state in an object under test.
A test assumes a specific order or exact string matches when that isn’t required.
Let’s look at some examples of overspecified tests.
一个非常常见的反模式是验证类或模块中的内部函数是否被调用,而不是检查工作单元的退出点。这是一个调用内部函数的密码验证器,测试不应该关心该函数。
A very common antipattern is to verify that an internal function in a class or module is called, instead of checking the exit point of the unit of work. Here’s a password verifier that calls an internal function, which the test shouldn’t care about.
Listing 8.11 Production code that calls a protected function
导出类PasswordVerifier4 {
私人_规则:((输入:字符串)=>布尔)[];
私人_logger:IComplicatedLogger;
构造函数(规则:((输入)=>布尔值)[],
记录器:IComplicatedLogger) {
this._rules = 规则;
this._logger = 记录器;
}
验证(输入:字符串):布尔值{
const failed = this.findFailedRules(input); ❶
if (失败.length === 0) {
this._logger.info("通过");
返回真;
}
this._logger.info("失败");
返回假;
}
受保护的 findFailedRules(输入:字符串) { ❷
const 失败 = this._rules
.map((规则) => 规则(输入))
.filter((结果) => 结果 === false);
返回失败;
}
}export class PasswordVerifier4 {
private _rules: ((input: string) => boolean)[];
private _logger: IComplicatedLogger;
constructor(rules: ((input) => boolean)[],
logger: IComplicatedLogger) {
this._rules = rules;
this._logger = logger;
}
verify(input: string): boolean {
const failed = this.findFailedRules(input); ❶
if (failed.length === 0) {
this._logger.info("PASSED");
return true;
}
this._logger.info("FAIL");
return false;
}
protected findFailedRules(input: string) { ❷
const failed = this._rules
.map((rule) => rule(input))
.filter((result) => result === false);
return failed;
}
}
❶ Call to the internal function
请注意,我们调用受保护的findFailedRules函数来获取结果,然后对结果进行计算。
Notice that we’re calling the protected findFailedRules function to get a result from it, and then doing a calculation on the result.
Listing 8.12 An overspecified test verifying a call to a protected function
描述(“验证者4”,()=> {
描述(“过度指定受保护的函数调用”,()=> {
test("checkfailedFules 被调用", () => {
const pv4 = 新密码验证器4(
[], Substitute.for<IComplicatedLogger>()
);
const failedMock = jest.fn(() => []); ❶
pv4["findFailedRules"] = failedMock; ❶
pv4.verify("abc");
期望(failedMock).toHaveBeenCalled(); ❷
});
});
});describe("verifier 4", () => {
describe("overspecify protected function call", () => {
test("checkfailedFules is called", () => {
const pv4 = new PasswordVerifier4(
[], Substitute.for<IComplicatedLogger>()
);
const failedMock = jest.fn(() => []); ❶
pv4["findFailedRules"] = failedMock; ❶
pv4.verify("abc");
expect(failedMock).toHaveBeenCalled(); ❷
});
});
});
❶ Mocking the internal function
❷ Verifying the internal function call. Don’t do this.
这里的反模式是我们正在证明一些不是出口点的东西。我们正在检查代码是否调用了某些内部函数,但这真正证明了什么?我们并不检查计算结果是否正确;而是检查结果是否正确。我们只是为了测试而测试。
The antipattern here is that we’re proving something that isn’t an exit point. We’re checking that the code calls some internal function, but what does that really prove? We’re not checking that the calculation was correct on the result; we’re simply testing for the sake of testing.
如果函数返回一个值,通常强烈表明我们不应该模拟该函数,因为函数调用本身并不代表退出点。退出点是函数返回的值verify()。我们不应该关心内部函数是否存在。
If the function is returning a value, usually that’s a strong indication that we shouldn’t mock that function because the function call itself does not represent the exit point. The exit point is the value returned from the verify() function. We shouldn’t care whether the internal function even exists.
通过验证不是出口点的受保护函数的模拟,我们将测试实现耦合到被测代码的内部实现,但没有真正的好处。当内部调用发生变化时(它们将会发生变化),我们还必须更改与这些调用相关的所有测试,这不会是一个积极的体验。您可以在 Vladimir Khorikov 的《单元测试原则、实践和模式》(Manning,2020 年)的第 5 章中阅读有关模拟及其与测试脆弱性的关系的更多信息。
By verifying against a mock of a protected function that is not an exit point, we are coupling our test implementation to the internal implementation of the code under test, for no real benefit. When the internal calls change (and they will) we will also have to change all the tests associated with these calls, and that will not be a positive experience. You can read more about mocks and their relation to test fragility in chapter 5 of Unit Testing Principles, Practices, and Patterns by Vladimir Khorikov (Manning, 2020).
Look for the exit point. The real exit point depends on the type of test we wish to perform:
基于值的测试——对于基于值的测试(我强烈建议您尽可能倾向于这种测试),我们从被调用的函数中查找返回值。在本例中,该verify函数返回一个值,因此它是基于值的测试的完美候选者:pv4.verify("abc")。
Value-based test—For a value-based test, which I would highly recommend you lean toward when possible, we look for a return value from the called function. In this case, the verify function returns a value, so it’s the perfect candidate for a value-based test: pv4.verify("abc").
基于状态的测试- 对于基于状态的测试,我们查找同级函数(与入口点存在于同一范围级别的函数)或受调用该verify()函数影响的同级属性。例如,firstname()和lastname()可以被视为兄弟函数。这就是我们应该断言的地方。verify()在此代码库中,同一级别可见的调用不会影响任何内容,因此它不是基于状态的测试的良好候选者。
State-based test—For a state-based test, we look for a sibling function (a function that exists at the same level of scope as the entry point) or a sibling property that is affected by calling the verify() function. For example, firstname() and lastname() could be considered sibling functions. That is where we should be asserting. In this codebase, nothing is affected by calling verify() that is visible at the same level, so it is not a good candidate for state-based testing.
第三方测试——对于第三方测试,我们必须使用模拟,这需要我们找出代码中的即发即忘位置。该findFailedRules函数不是这样,因为它实际上是将信息传递回我们的verify()函数。在这种情况下,我们不需要接管真正的第三方依赖。
Third-party test—For a third-party test, we would have to use a mock, and that would require us to find out where the fire-and-forget location is inside the code. The findFailedRules function isn’t that, because it is actually delivering information back to our verify() function. In this case, there’s no real third-party dependency that we have to take over.
常见的反模式是测试过度指定返回值集合的顺序和结构。在断言中指定整个集合及其每个项目通常更容易,但是使用这种方法,当集合的任何小细节发生变化时,我们隐式地承担了修复测试的负担。我们不应该使用单个巨大的断言,而应该将验证的不同方面分成更小的、显式的断言。
A common antipattern is when a test overspecifies the order and the structure of a collection of returned values. It’s often easier to specify the whole collection, along with each of its items, in the assertion, but with this approach, we implicitly take on the burden of fixing the test when any little detail of the collection changes. Instead of using a single huge assertion, we should separate different aspects of the verification into smaller, explicit asserts.
以下清单显示了一个verify()接受多个输入并返回结果对象列表的函数。
The following listing shows a verify() function that takes on multiple inputs and returns a list of result objects.
Listing 8.13 A verifier that returns a list of outputs
接口 IResult {
结果:布尔值;
输入:字符串;
}
导出类PasswordVerifier5 {
私人_规则:((输入:字符串)=>布尔)[];
构造函数(规则:((输入)=>布尔值)[]){
this._rules = 规则;
}
verify(inputs: string[]): IResult[] {
const failedResults =
input.map((input) => this.checkSingleInput(input));
返回失败结果;
}
私人 checkSingleInput(输入:字符串):IResult {
const failed = this.findFailedRules(input);
返回 {
输入,
结果:失败。长度=== 0,
};
}interface IResult {
result: boolean;
input: string;
}
export class PasswordVerifier5 {
private _rules: ((input: string) => boolean)[];
constructor(rules: ((input) => boolean)[]) {
this._rules = rules;
}
verify(inputs: string[]): IResult[] {
const failedResults =
inputs.map((input) => this.checkSingleInput(input));
return failedResults;
}
private checkSingleInput(input: string): IResult {
const failed = this.findFailedRules(input);
return {
input,
result: failed.length === 0,
};
}
该函数返回一个对象verify()数组,每个对象中都有一个and 。以下清单显示了一个测试,该测试对结果的排序和每个结果的结构进行隐式检查,并检查结果的值。IResultinputresult
This verify() function returns an array of IResult objects with an input and result in each. The following listing shows a test that makes an implicit check on both the ordering of the results and the structure of each result, as well as checking the value of the results.
Listing 8.14 Overspecifying order and schema of the result
test("过度指定顺序和模式", () => {
常量 pv5 =
新的PasswordVerifier5([input => input.includes("abc")]);
const 结果 = pv5.verify(["a", "ab", "abc", "abcd"]);
Expect(results).toEqual([ ❶
{ 输入: "a", 结果: false }, ❶
{ 输入: "ab", 结果: false }, ❶
{ 输入: "abc", 结果: true }, ❶
{ 输入: "abcd", 结果: true }, ❶
]);
});test("overspecify order and schema", () => {
const pv5 =
new PasswordVerifier5([input => input.includes("abc")]);
const results = pv5.verify(["a", "ab", "abc", "abcd"]);
expect(results).toEqual([ ❶
{ input: "a", result: false }, ❶
{ input: "ab", result: false }, ❶
{ input: "abc", result: true }, ❶
{ input: "abcd", result: true }, ❶
]);
});
How might this test change in the future? Here are quite a few reasons for it to change:
When each result object gains or removes a property (even if the test doesn’t care about those properties)
When the order of the results changes (even if it might not be important for the current test)
如果将来发生任何这些变化,但您的测试只是专注于检查验证器的逻辑及其输出的结构,那么维护此测试将会带来很多痛苦。
If any of these changes happens in the future, but your test is just focused on checking the logic of the verifier and the structure of its output, there’s going to be a lot of pain involved in maintaining this test.
We can reduce some of that pain by verifying only the parts that matter to us.
Listing 8.15 Ignoring the schema of the results
test("过度指定顺序但忽略模式", () => {
常量 pv5 =
新的PasswordVerifier5([(input) => input.includes("abc")]);
const 结果 = pv5.verify(["a", "ab", "abc", "abcd"]);
期望(结果.长度).toBe(4);
期望(结果[0] 。结果)。toBe(假);
期望(结果[1] 。结果)。toBe(假);
期望(结果[2] .结果).toBe(true);
期望(结果[3] .结果).toBe(true);
});test("overspecify order but ignore schema", () => {
const pv5 =
new PasswordVerifier5([(input) => input.includes("abc")]);
const results = pv5.verify(["a", "ab", "abc", "abcd"]);
expect(results.length).toBe(4);
expect(results[0].result).toBe(false);
expect(results[1].result).toBe(false);
expect(results[2].result).toBe(true);
expect(results[3].result).toBe(true);
});
我们可以简单地断言输出中特定属性的值,而不是提供完整的预期输出。然而,如果结果的顺序发生变化,我们仍然会陷入困境。如果我们不关心顺序,我们可以简单地检查输出是否包含特定结果,如下所示。
Instead of providing the full expected output, we can simply assert on the values of specific properties in the output. However, we’re still stuck if the order of the results changes. If we don’t care about the order, we can simply check if the output contains a specific result, as follows.
Listing 8.16 Ignoring order and schema
test("忽略顺序和模式", () => {
常量 pv5 =
新的PasswordVerifier5([(input) => input.includes("abc")]);
const 结果 = pv5.verify(["a", "ab", "abc", "abcd"]);
期望(结果.长度).toBe(4);
期望(findResultFor(“a”))。toBe(假);
期望(findResultFor(“ab”))。toBe(假);
期望( findResultFor("abc") ).toBe(true);
期望( findResultFor("abcd") ).toBe(true);
});test("ignore order and schema", () => {
const pv5 =
new PasswordVerifier5([(input) => input.includes("abc")]);
const results = pv5.verify(["a", "ab", "abc", "abcd"]);
expect(results.length).toBe(4);
expect(findResultFor("a")).toBe(false);
expect(findResultFor("ab")).toBe(false);
expect(findResultFor("abc")).toBe(true);
expect(findResultFor("abcd")).toBe(true);
});
在这里,我们用于findResultFor()查找给定输入的特定结果。现在结果的顺序可以改变,或者可以添加额外的值,但是只有当正确或错误结果的计算发生改变时,我们的测试才会失败。
Here we are using findResultFor() to find the specific result for a given input. Now the order of the results can change, or extra values can be added, but our test will only fail if the calculation of the true or false results changes.
人们倾向于重复的另一个常见反模式是,当仅需要字符串的特定部分时,对单元的返回值或属性中的硬编码字符串进行断言。问问自己,“我可以检查一个字符串是否包含某些内容而不是等于某些内容吗?” 这是一个密码验证器,它向我们提供一条消息,描述验证过程中违反了多少规则。
Another common antipattern people tend to repeat is to assert against hardcoded strings in the unit’s return value or properties, when only a specific part of a string is necessary. Ask yourself, “Can I check if a string contains something rather than equals something?” Here’s a password verifier that gives us a message describing how many rules were broken during a verification.
Listing 8.17 A verifier that returns a string message
导出类PasswordVerifier6 {
私人_规则:((输入:字符串)=>布尔)[];
私人_味精:字符串=“”;
构造函数(规则:((输入)=>布尔值)[]){
this._rules = 规则;
}
getMsg(): string {
返回这个。_味精;
}
验证(输入:字符串[]):IResult[] {
常量所有结果=
input.map((input) => this.checkSingleInput(input));
this.setDescription(allResults);
返回所有结果;
}
私有 setDescription(结果: IResult[]) {
const failed = results.filter((res) => !res.result);
这。_ msg = `您有 ${failed.length} 条失败的规则。`;
}export class PasswordVerifier6 {
private _rules: ((input: string) => boolean)[];
private _msg: string = "";
constructor(rules: ((input) => boolean)[]) {
this._rules = rules;
}
getMsg(): string {
return this._msg;
}
verify(inputs: string[]): IResult[] {
const allResults =
inputs.map((input) => this.checkSingleInput(input));
this.setDescription(allResults);
return allResults;
}
private setDescription(results: IResult[]) {
const failed = results.filter((res) => !res.result);
this._msg = `you have ${failed.length} failed rules.`;
}
The following listing shows two tests that use getMsg().
Listing 8.18 Overspecifying a string using equality
描述(“验证者6”,()=> {
test("超过指定字符串", () => {
常量 pv5 =
新的PasswordVerifier6([(输入) => input.includes("abc")]);
pv5.verify(["a", "ab", "abc", "abcd"]);
const msg = pv5.getMsg();
Expect(msg).toBe("你有 2 个失败的规则。"); ❶
});
//这是编写此测试的更好方法
test("更多面向未来的字符串检查", () => {
常量 pv5 =
新的PasswordVerifier6([(输入) => input.includes("abc")]);
pv5.verify(["a", "ab", "abc", "abcd"]);
const msg = pv5.getMsg();
Expect(msg).toMatch(/2 失败/); ❷
});
});describe("verifier 6", () => {
test("over specify string", () => {
const pv5 =
new PasswordVerifier6([(input) => input.includes("abc")]);
pv5.verify(["a", "ab", "abc", "abcd"]);
const msg = pv5.getMsg();
expect(msg).toBe("you have 2 failed rules."); ❶
});
//Here's a better way to write this test
test("more future proof string checking", () => {
const pv5 =
new PasswordVerifier6([(input) => input.includes("abc")]);
pv5.verify(["a", "ab", "abc", "abcd"]);
const msg = pv5.getMsg();
expect(msg).toMatch(/2 failed/); ❷
});
});
❶ Overly specific string expectation
❷ A better way to assert against a string
第一个测试检查该字符串是否完全等于另一个字符串。这常常适得其反,因为字符串是用户界面的一种形式。随着时间的推移,我们倾向于稍微改变它们并修饰它们。例如,我们关心字符串末尾有一个句点吗?我们的测试需要我们关心,但断言的实质是显示正确的数字(特别是因为字符串在不同的计算机语言或文化中发生变化,但数字通常保持不变)。
The first test checks that the string exactly equals another string. This backfires often, because strings are a form of user interface. We tend to change them slightly and embellish them over time. For example, do we care that there is a period at the end of the string? Our test requires us to care, but the meat of the assert is the correct number being shown (especially since strings change in different computer languages or cultures, but numbers usually stay the same).
第二个测试只是在消息中查找“2 failed”字符串。这使得测试更加面向未来:字符串可能会略有变化,但核心消息仍然存在,而不会迫使我们更改测试。
The second test simply looks for the “2 failed” string inside the message. This makes the test more future-proof: the string might change slightly, but the core message remains without forcing us to change the test.
测试随着被测系统的发展和变化而变化。如果我们不注意可维护性,我们的测试可能需要我们进行太多更改,以至于可能不值得更改它们。相反,我们最终可能会删除它们,并放弃创建它们的所有辛苦工作。为了使测试从长远来看有用,它们应该只因我们真正关心的原因而失败。
Tests grow and change with the system under test. If we don’t pay attention to maintainability, our tests may require so many changes from us that it might not be worth changing them. We may instead end up deleting them, and throwing away all the hard work that went into creating them. For tests to be useful in the long run, they should fail only for reasons we truly care about.
A true failure is when a test fails because it finds a bug in production code. A false failure is when a test fails for any other reason.
To estimate test maintainability, we can measure the number of false test failures and the reason for each failure, over time.
测试可能会因多种原因而错误地失败:它与另一个测试冲突(在这种情况下,您应该将其删除);生产代码 API 的更改(可以通过使用工厂和辅助方法来缓解);其他测试中的更改(此类测试应相互解耦)。
A test may falsely fail for multiple reasons: it conflicts with another test (in which case, you should just remove it); changes in the production code’s API (this can be mitigated by using factory and helper methods); changes in other tests (such tests should be decoupled from each other).
避免测试私有方法。私有方法是实现细节,并且生成的测试将是脆弱的。测试应该验证可观察的行为——与最终用户相关的行为。有时,需要测试私有方法是缺少抽象的标志,这意味着该方法应该公开,甚至提取到单独的类中。
Avoid testing private methods. Private methods are implementation details, and the resulting tests are going to be fragile. Tests should verify observable behavior—behavior that is relevant for the end user. Sometimes, the need to test a private method is a sign of a missing abstraction, which means the method should be made public or even be extracted into a separate class.
Keep tests DRY. Use helper methods to abstract nonessential details of arrange and assert sections. This will simplify your tests without coupling them to each other.
避免使用诸如函数之类的设置方法beforeEach。再次使用辅助方法。另一种选择是参数化您的测试,从而将块的内容移动beforeEach到测试的排列部分。
Avoid setup methods such as the beforeEach function. Once again, use helper methods instead. Another option is to parameterize your tests and therefore move the content of the beforeEach block to the test’s arrange section.
避免过度规范。过度指定的示例包括断言被测代码的私有状态、断言对桩的调用,或者在不需要时假设结果集合中元素的特定顺序或精确的字符串匹配。
Avoid overspecification. Examples of overspecification are asserting the private state of the code under test, asserting against calls on stubs, or assuming the specific order of elements in a result collection or exact string matches when that isn’t required.
最后几章涵盖了向现有组织或代码库引入单元测试时将面临的问题以及所需的技术。
These final chapters cover the problems you’ll face and the techniques you’ll need when introducing unit testing to an existing organization or codebase.
在第 9 章中,我们将讨论测试可读性。我们将讨论测试的命名约定和它们的输入值。我们还将介绍测试构建和编写更好的断言消息的最佳实践。
In chapter 9, we’ll talk about test readability. We’ll discuss naming conventions for tests and input values for them. We’ll also cover best practices for test structuring and writing better assertion messages.
第 10 章解释了如何制定测试策略。我们将了解在测试新功能时您应该选择哪些测试级别,讨论测试级别中的常见反模式,并讨论测试配方策略。
Chapter 10 explains how to develop a testing strategy. We’ll look at which test levels you should prefer when testing a new feature, discuss common antipatterns in test levels, and talk about the test recipe strategy.
在第11章中,我们将处理在组织中实施单元测试的棘手问题,并且我们将介绍可以使您的工作变得更轻松的技术。本章提供了首次实施单元测试时常见的一些棘手问题的答案。
In chapter 11, we’ll deal with the tough issue of implementing unit testing in an organization, and we’ll cover techniques that can make your job easier. This chapter provides answers to some tough questions that are common when first implementing unit testing.
在第 12 章中,我们将研究与遗留代码相关的常见问题,并研究一些处理它的工具。
In chapter 12, we’ll look at common problems associated with legacy code and examine some tools for working with it.
如果没有可读性,您编写的测试对于以后阅读它们的人来说几乎毫无意义。可读性是测试编写者和几个月或几年后必须阅读测试的可怜人之间的连接线。测试是您在项目中向下一代程序员讲述的故事。它们使开发人员能够准确地了解应用程序的组成部分以及应用程序的启动位置。
Without readability, the tests you write are almost meaningless to whoever reads them later on. Readability is the connecting thread between the person who wrote the test and the poor soul who must read it a few months or years later. Tests are stories you tell the next generation of programmers on a project. They allow a developer to see exactly what an application is made of and where it started.
本章的重点是确保您之后的开发人员能够维护您编写的生产代码和测试。他们需要了解自己在做什么以及应该在哪里做。
This chapter is all about making sure the developers who come after you will be able to maintain the production code and the tests that you write. They’ll need to understand what they’re doing and where they should be doing it.
There are several facets to readability:
Let’s go through these one by one.
命名标准很重要,因为它们为您提供了舒适的规则和模板,概述了您应该解释的测试内容。无论我如何订购它们,或者使用什么特定的框架或语言,我都会尝试确保这三个重要信息出现在测试名称或测试所在文件的结构中:
Naming standards are important because they give you comfortable rules and templates that outline what you should explain about the test. No matter how I order them, or what specific framework or language I am using, I try to make sure these three important pieces of information are present in the name of the test or in the structure of the file in which the test exists:
入口点(或工作单元)的名称至关重要,以便您可以轻松了解正在测试的逻辑的起始范围。将此作为测试名称的第一部分还可以在测试文件中轻松导航和键入完成(如果您的 IDE 支持)。
The name of the entry point (or unit of work) is essential, so that you can easily understand the starting scope of the logic being tested. Having this as the first part of the test name also allows for easy navigation and as-you-type completion (if your IDE supports it) in the test file.
测试它的场景为您提供了名称的“with”部分:“当我使用空值调用入口点 X 时,它应该执行 Y。”
The scenario under which it’s being tested gives you the “with” part of the name: “When I call entry point X with a null value, then it should do Y.”
工作单元出口点的预期行为是测试根据当前场景,用简单的英语指定工作单元应该做什么或返回什么,或者它应该如何表现:“当我使用空值,那么它应该执行从工作单元的出口点可见的 Y 操作。”
The expected behavior from the exit point of the unit of work is where the test specifies in plain English what the unit of work should do or return, or how it should behave, based on the current scenario: “When I call entry point X with a null value, then it should do Y as visible from this exit point of the unit of work.”
这三个要素必须存在于阅读测试的人眼睛附近的某个地方。有时它们可以全部封装在测试的函数名称中,有时您可以将它们包含在嵌套describe结构中。有时您可以简单地使用字符串描述作为测试的参数或注释。
These three elements have to exist somewhere close to the eyes of the person reading the test. Sometimes they can all be encapsulated in the test’s function name, and sometimes you can include them with nested describe structures. Sometimes you can simply use a string description as a parameter or annotation for the test.
下面的清单中显示了一些示例,所有示例都具有相同的信息,但布局不同。
Some examples are shown in the following listing, all with the same pieces of information, but laid out differently.
Listing 9.1 Same information, different variations
test('verifyPassword,规则失败,根据rule.reason返回错误', () => { ... }
描述('验证密码',()=> {
描述('有一个失败的规则', () => {
it('根据规则返回错误。原因', () => { ... }
verifyPassword_withFailingRule_returnsErrorBasedonRuleReason()test('verifyPassword, with a failing rule, returns error based on rule.reason', () => { ... }
describe('verifyPassword', () => {
describe('with a failing rule', () => {
it('returns error based on the rule.reason', () => { ... }
verifyPassword_withFailingRule_returnsErrorBasedonRuleReason()
当然,您可以想出其他方法来构建它。(谁说必须使用下划线?这只是我自己的偏好,旨在提醒我和其他人有三条信息。)。需要注意的关键点是,如果删除其中一条信息,就会迫使阅读测试的人阅读测试中的代码以找出答案,从而浪费宝贵的时间。
You can, of course, come up with other ways to structure this. (Who says you have to use underscores? That’s just my own preference for reminding me and others that there are three pieces of information.). The key point to take away is that if you remove one of these pieces of information, you’re forcing the person reading the test to read the code inside the test to find out the answer, wasting precious time.
The following listing shows examples of tests with missing information.
Listing 9.2 Test names with missing information
test(失败的规则,根据rule.reason返回错误', () => { ... } ❶
test('verifyPassword, 根据rule.reason返回错误', () => { ... } ❷
test('verifyPassword, 规则失败', () => { ... } ❸test(failing rule, returns error based on rule.reason', () => { ... } ❶
test('verifyPassword, returns error based on rule.reason', () => { ... } ❷
test('verifyPassword, with a failing rule', () => { ... } ❸
❶ What is the thing under test?
❷ When is this supposed to happen?
❸ What’s supposed to happen then?
可读性的主要目标是将下一个开发人员从阅读测试代码的负担中解放出来,以便了解测试正在测试什么。
Your main goal with readability is to release the next developer from the burden of reading the test code in order to understand what the test is testing.
在测试名称中包含所有这些信息的另一个重要原因是,当自动构建管道失败时,该名称通常是唯一显示的内容。您将在失败的构建日志中看到失败测试的名称,但不会看到任何注释或测试代码。如果名称足够好,您可能不需要阅读测试代码或调试它们;只需阅读失败构建的日志,您就可以了解失败的原因。这可以节省宝贵的调试和阅读时间。
Another great reason to include all these pieces of information in the name of the test is that the name is usually the only thing that shows up when an automated build pipeline fails. You’ll see the names of the failed tests in the log of the build that failed, but you won’t see any comments or the code of the tests. If the names are good enough, you might not need to read the code of the tests or debug them; you may understand the cause of the failure simply by reading the log of the failed build. This can save precious debugging and reading time.
一个好的测试名称还有助于促进可执行文档的想法——如果您可以要求团队中的新开发人员阅读测试,以便他们能够了解特定组件或应用程序的工作原理,这是可读性的良好标志。如果他们无法仅通过测试来理解应用程序或组件的行为,那么这可能是可读性的危险信号。
A good test name also serves to contribute to the idea of executable documentation—if you can ask a developer who is new to the team to read the tests so they can understand how a specific component or application works, that’s a good sign of readability. If they can’t make sense of the application or the component’s behavior from the tests alone, it might be a red flag for readability.
您听说过“神奇价值观”这个词吗?听起来很棒,但事实恰恰相反。它实际上应该是“巫术价值观”来传达使用它们的负面影响。你问它们是什么?它们是硬编码的、未记录的或难以理解的常量或变量。提到魔法表明这些值有效,但你不知道为什么。
Have you heard the term “magic values”? It sounds awesome, but it’s the opposite of that. It should really be “witchcraft values” to convey the negative effects of using them. What are they, you ask? They are hardcoded, undocumented, or poorly understood constants or variables. The reference to magic indicates that these values work, but you have no idea why.
Listing 9.3 A test with magic values
描述('密码验证器', () => {
test('周末,抛出异常', () => {
)) ❶
.toThrowError("周末了!");
});
});describe('password verifier', () => {
test('on weekends, throws exceptions', () => {
)) ❶
.toThrowError("It's the weekend!");
});
});
该测试包含三个神奇值。一个没有写过测试、不知道被测试的API的人能轻易理解这个0值的含义吗?数组怎么样[]?该函数的第一个参数看起来像一个密码,但即使如此,它也具有神奇的品质。来!我们讨论一下:
This test contains three magic values. Can a person who didn’t write the test and doesn't know the API being tested easily understand what the 0 value means? How about the [] array? The first parameter to that function kind of looks like a password, but even that has a magical quality to it. Let’s discuss:
The 0 could mean so many things. As the reader, I might have to search around in the code, or jump into the signature of the called function, to understand that this specifies the day of the week.
The [] forces me to look at the signature of the called function to understand that the function expects a password verification rule array, which means the test verifies the case with no rules.
jhGGu78!似乎是一个明显的密码值,但作为读者我会遇到的一个大问题是,为什么这个特定值?这个特定密码有什么重要意义?对于这个测试来说,使用这个值而不是任何其他值显然很重要,因为它看起来非常具体。事实上并非如此,但读者不会知道这一点。为了安全起见,他们最终可能会在其他测试中使用此密码。魔法值往往会在测试中自我传播。
jhGGu78! seems to be an obvious password value, but the big question I’ll have as a reader is, why this specific value? What’s important about this specific password? It’s obviously important to use this value and not any other for this test, because it seems so damned specific. In reality it isn’t, but the reader won’t know this. They’ll likely end up using this password in other tests just to be safe. Magic values tend to propagate themselves in tests.
The following listing shows the same test with the magic values fixed.
Listing 9.4 Fixing magic values
描述(“verifier2 - 虚拟对象”,()=> {
test("周末,抛出异常", () => {
const SUNDAY = 0, NO _规则 = [];
期望(()=> verifyPassword2(“任何东西”,没有_规则,周日))
.toThrowError("周末了!");
});
});describe("verifier2 - dummy object", () => {
test("on weekends, throws exceptions", () => {
const SUNDAY = 0, NO_RULES = [];
expect(() => verifyPassword2("anything", NO_RULES, SUNDAY))
.toThrowError("It's the weekend!");
});
});
通过将神奇值放入有意义的命名变量中,我们可以消除人们在阅读我们的测试时会产生的疑问。对于密码值,我决定简单地更改直接值,以向读者解释此测试中哪些内容不重要。
By putting magic values into meaningfully named variables, we can remove the questions people will have when reading our test. For the password value, I’ve decided to simply change the direct value to explain to the reader what is not important about this test.
变量名和值不仅向读者解释什么是重要的,还向读者解释了他们不应该关心的内容。
Variable names and values are just as much about explaining to the reader what they should not care about as they are about explaining what is important.
为了可读性和所有神圣的目的,避免在同一个语句中编写断言和方法调用。下面的清单显示了我的意思。
For the sake of readability and all that is holy, avoid writing assertions and the method call in the same statement. The following listing shows what I mean.
Listing 9.5 Separating asserts from actions
Expect(verifier.verify("任意值")[0]).toContain("假原因"); ❶
const result = verifier.verify("任何值"); ❷expect
(result[0]).toContain("假原因"); ❷expect(verifier.verify("any value")[0]).toContain("fake reason"); ❶
const result = verifier.verify("any value"); ❷
expect(result[0]).toContain("fake reason"); ❷
看到两个例子之间的区别了吗?由于行的长度以及行为和断言部分的嵌套,第一个示例在实际测试的上下文中更难以阅读和理解。
See the difference between the two examples? The first example is much harder to read and understand in the context of a real test because of the length of the line and the nesting of the act and assert parts.
如果您想在调用后关注结果值,那么调试第二个示例也比第一个示例容易得多。不要吝惜这个小技巧。当你的测试没有让你后面的人因为不理解它而感到愚蠢时,他们会低声说一声谢谢。
It’s also much easier to debug the second example than the first one, if you wanted to focus on the result value after the call. Don’t skimp on this small tip. The people after you will whisper a small thank you when your test doesn’t make them feel stupid for not understanding it.
单元测试中的设置和拆卸方法可能会被滥用,导致测试或设置和拆卸方法变得不可读。setup方法中的情况通常比teardown方法中的情况更糟糕。
Setup and teardown methods in unit tests can be abused to the point where the tests or the setup and teardown methods are unreadable. The situation is usually worse in the setup method than in the teardown method.
以下清单显示了一种非常常见的可能滥用:使用设置(或beforeEach函数)来设置模拟或桩。
The following listing shows one possible abuse that is very common: using the setup (or beforeEach function) for setting up mocks or stubs.
清单 9.6 使用 setup ( beforeEach) 函数进行模拟设置
Listing 9.6 Using a setup (beforeEach) function for mock setup
描述(“密码验证器”,()=> {
让模拟日志;
之前(()=> {
mockLog = Substitute.for<IComplicatedLogger>(); ❶
});
test("验证,使用记录器并通过,使用 PASS 调用记录器",() => {
const verifier = new PasswordVerifier2([], mockLog ); ❷
verifier.verify("任何东西");
mockLog.received ().info( ❷
Arg.is((x) => x.includes("通过")),
“核实”
);
});
});describe("password verifier", () => {
let mockLog;
beforeEach(() => {
mockLog = Substitute.for<IComplicatedLogger>(); ❶
});
test("verify, with logger & passing, calls logger with PASS",() => {
const verifier = new PasswordVerifier2([], mockLog); ❷
verifier.verify("anything");
mockLog.received().info( ❷
Arg.is((x) => x.includes("PASSED")),
"verify"
);
});
});
如果您在设置方法中设置模拟和桩,则意味着它们不会在实际测试中设置。反过来,这意味着无论谁正在阅读您的测试,都可能没有意识到正在使用模拟对象,或者测试对它们的期望。
If you set up mocks and stubs in a setup method, that means they don’t get set up in the actual test. That, in turn, means that whoever is reading your test may not even realize that there are mock objects in use, or what the test expects from them.
清单 9.6 中的测试使用了该变量,该变量在函数(设置方法)mockLog中初始化。beforeEach想象一下您的文件中有数十个或更多这样的测试。设置函数位于文件的开头,而您只能在文件中向下读取测试路径。当你遇到这个mockLog变量时,你必须开始问这样的问题:“这个变量是在哪里初始化的?它在测试中会表现如何?” 和更多。
The test in listing 9.6 uses the mockLog variable, which is initialized in the beforeEach function (a setup method). Imagine you have dozens or more of these tests in the file. The setup function is at the beginning of the file, and you are stuck reading a test way down in the file. You come across the mockLog variable and you have to start asking questions such as, “Where is this initialized? How will it behave in the test?” and more.
如果在同一文件中的各种测试中使用多个模拟和桩,则可能出现的另一个问题是设置函数成为测试使用的所有各种状态的转储组。它变得一团糟,一堆参数,一些被一个测试使用,另一些在其他地方使用。管理和理解这样的设置变得很困难。
Another problem that can arise if multiple mocks and stubs are used in various tests in the same file is that the setup function becomes a dumping group for all the various states used by your tests. It becomes a big mess, a soup of many parameters, some used by one test and others used somewhere else. It becomes difficult to manage and understand such a setup.
直接在测试中初始化模拟对象更具可读性,符合他们的所有期望。以下清单是在每个测试中初始化模拟的示例。
It’s much more readable to initialize mock objects directly in the test, with all their expectations. The following listing is an example of initializing the mock in each test.
Listing 9.7 Avoiding a setup function
描述(“密码验证器”,()=> {
test("验证,使用记录器并通过,使用 PASS 调用记录器",() => {
const mockLog = Substitute.for<IComplicatedLogger>(); ❶
constverifier=newPasswordVerifier2([],mockLog);
verifier.verify("任何东西");
mockLog.received().info(
Arg.is((x) => x.includes("通过")),
“核实”
);
});describe("password verifier", () => {
test("verify, with logger & passing,calls logger with PASS",() => {
const mockLog = Substitute.for<IComplicatedLogger>(); ❶
const verifier = new PasswordVerifier2([], mockLog);
verifier.verify("anything");
mockLog.received().info(
Arg.is((x) => x.includes("PASSED")),
"verify"
);
});
❶ Initializing the mock in the test
当我看到这个测试时,一切都一目了然。我可以看到模拟何时创建、其行为以及我需要知道的任何其他信息。
When I look at this test, everything is clear as day. I can see when the mock is created, its behavior, and anything else I need to know.
如果您担心可维护性,可以将模拟的创建重构为每个测试都会调用的辅助函数。这样,您就可以避免使用通用设置函数,而是从多个测试中调用相同的辅助函数。正如下面的清单所示,您可以保持可读性并获得更多的可维护性。
If you’re worried about maintainability, you can refactor the creation of the mock into a helper function that each test would call. That way, you’re avoiding the generic setup function and are instead calling the same helper function from multiple tests. As the following listing shows, you keep the readability and gain more maintainability.
Listing 9.8 Using a helper function
描述(“密码验证器”,()=> {
test("验证,使用记录器并通过,使用 PASS 调用记录器",() => {
const mockLog = makeMockLogger(); ❶
constverifier=newPasswordVerifier2([],mockLog);
verifier.verify("任何东西");
mockLog.received().info(
Arg.is((x) => x.includes("通过")),
“核实”
);
});describe("password verifier", () => {
test("verify, with logger & passing,calls logger with PASS",() => {
const mockLog = makeMockLogger(); ❶
const verifier = new PasswordVerifier2([], mockLog);
verifier.verify("anything");
mockLog.received().info(
Arg.is((x) => x.includes("PASSED")),
"verify"
);
});
❶ Using a helper function to initialize the mock
是的,如果您遵循这个逻辑,您会发现我完全同意您在测试中没有任何设置功能。为了可维护性,我经常编写没有设置函数的完整测试套件,而是从每个测试中调用辅助方法。测试仍然具有可读性和可维护性。
And yes, if you follow this logic, you can see that I’m perfectly OK with you not having any setup functions in your tests. I’ve often written full test suites that don’t have a setup function, instead calling helper methods from each test, for the sake of maintainability. The tests were still readable and maintainable.
When naming a test, include the name of the unit of work under test, the current test scenario, and the expected behavior of the unit of work.
Don’t leave magic values in your tests. Either wrap them in variables with meaningful names, or put the description into the value itself, if it’s a string.
Separate assertions from actions. Merging the two shortens the code but makes it significantly harder to understand.
尽量不要使用测试设置(例如beforeEach方法)。引入辅助方法来简化测试的排列部分,并在每个测试中使用这些辅助方法。
Try not to use test setups at all (such as beforeEach methods). Introduce helper methods to simplify the test’s arrange part, and use those helper methods in each test.
单元测试仅代表您可以并且应该编写的测试类型之一。在本章中,我们将讨论单元测试如何适应组织测试策略。一旦我们开始研究其他类型的测试,我们就开始提出一些非常重要的问题:
Unit tests represent just one of the types of tests you could and should write. In this chapter, we’ll discuss how unit testing fits into an organizational testing strategy. As soon as we start to look at other types of tests, we start asking some really important questions:
At what level do we want to test various features? (UI, backend, API, unit, etc.)
How do we decide at which level to test a feature? Do we test it multiple times on many levels?
Should we have more functional end-to-end tests or more unit tests?
How can we optimize the speed of tests without sacrificing trust in them?
The answers to these questions, and many more, are what I’d call a testing strategy.
The first step in our journey is to frame the scope of the testing strategy in terms of test types.
不同的行业可能有不同的测试类型和级别。我们在第 7 章中首次讨论的图 10.1 是一组相当通用的测试类型,我认为它适合我咨询过的 90%(甚至更多)的组织。测试的级别越高,它们使用的依赖项就越真实,这让我们对整个系统的正确性充满信心。缺点是此类测试速度较慢且不稳定。
Different industries might have different test types and levels. Figure 10.1, which we first discussed in chapter 7, is a rather generic set of test types that I feel fits 90% of the organizations I consult with, if not more. The higher the level of the tests, the more real dependencies they use, which gives us confidence in the overall system’s correctness. The downside is that such tests are slower and flakier.
Figure 10.1 Common software test levels
很好的图表,但是我们用它做什么呢?当我们设计一个框架来决定要编写哪个测试时,我们会使用它。我喜欢指出几个标准(使我们的工作变得更容易或更困难的因素);这些帮助我决定使用哪种测试类型。
Nice diagram, but what do we do with it? We use it when we design a framework for decision making about which test to write. There are several criteria (things that make our jobs easier or harder) I like to pinpoint; these help me decide which test type to use.
当我们面临两个以上的选择时,我发现帮助我做出决定的最好方法之一就是弄清楚我对当前问题的明显价值观是什么。这些明显的价值观是我们在做出选择时几乎都同意有用或应该避免的事情。表 10.1 列出了我的明显测试值。
When we’re faced with more than two options to choose from, one of the best ways I’ve found to help me decide is to figure out what my obvious values are for the problem at hand. These obvious values are the things we can all pretty much agree are useful or should be avoided when making the choice. Table 10.1 lists my obvious values for tests.
Table 10.1 Generic test scorecard
所有值都从 1 到 5 缩放。正如您将看到的,图 10.1 中的每个级别在每个标准中都有优点和缺点。
All values are scaled from 1 to 5. As you’ll see, each level in figure 10.1 has pros and cons in each of these criteria.
单元测试和组件测试是我们进行过的测试类型本书到目前为止所讨论的内容。它们都属于同一类别,唯一的区别是组件测试可能有更多的函数、类或组件作为工作单元的一部分。换句话说,组件测试在入口点和出口点之间包含更多“东西”。
Unit tests and component tests are the types of tests we’ve been discussing in this book so far. They both fit under the same category, with the only differentiation being that component tests might have more functions, classes, or components as part of the unit of work. In other words, component tests include more “stuff” between the entry and exit points.
Here are two test examples to illustrate the difference:
Test A—A unit test of a custom UI button object in memory. You can instantiate it, click it, and see that it triggers some form of click event.
测试 B — 实例化更高级别表单组件并将按钮作为其结构一部分的组件测试。该测试验证了更高级别的表单,按钮在更高级别的场景中扮演了一个小角色。
Test B—A component test that instantiates a higher-level form component and includes the button as part of its structure. The test verifies the higher-level form, with the button playing a small role as part of the higher-level scenario.
这两个测试仍然是内存中的单元测试,我们可以完全控制正在使用的所有东西;不依赖于文件、数据库、网络、配置或我们无法控制的其他事物。测试 A 是较低级别的单元测试,测试 B 是组件测试,或者较高级别的单元测试。
Both tests are still unit tests, in memory, and we have full control over all the things being used; there are no dependencies on files, databases, networks, configuration, or other things we don’t control. Test A is a lower-level unit test, and test B is a component test, or a higher-level unit test.
需要进行这种区分的原因是因为我经常被问到什么是具有不同抽象级别的测试。答案是,测试是否属于单元/组件测试类别取决于它具有或不具有的依赖关系,而不是基于它使用的抽象级别。表 10.2 显示了单元/组件测试层的记分卡。
The reason this differentiation needs to be made is because I often get asked what I would call a test with a different level of abstraction. The answer is that whether a test falls into the unit/component test category is based on the dependencies it does or doesn’t have, not on the abstraction level it uses. Table 10.2 shows the scorecard for the unit/component test layer.
Table 10.2 Unit/component test scorecard
集成测试看起来几乎与常规单元测试一模一样,但某些依赖项并未被消除。例如,我们可能使用真实的配置、真实的数据库、真实的文件系统或全部三者。但为了调用测试,我们仍然从内存中的生产代码实例化一个对象,并直接在该对象上调用入口点函数。表 10.3 显示了集成测试的记分卡。
Integration tests look almost exactly like regular unit tests, but some of the dependencies are not stubbed out. For example, we might use a real configuration, a real database, a real filesystem, or all three. But to invoke the test, we still instantiate an object from our production code in memory and invoke an entry point function directly on that object. Table 10.3 shows the scorecard for integration tests.
Table 10.3 Integration test scorecard
在之前较低级别的测试中,我们不需要部署被测应用程序或使其正确运行来进行测试。在API测试级别,我们最终需要部署(至少部分)被测应用程序并通过网络调用它。与单元、组件和集成测试(可归类为内存内测试)不同,API 测试是进程外测试。我们不再直接在内存中实例化被测单元。这意味着我们要在组合中添加一个新的依赖项:网络以及某些网络服务的部署。表 10.4 显示了 API 测试的记分卡。
In previous lower levels of tests, we haven’t needed to deploy the application under test or make it properly run to test it. At the API test level, we finally need to deploy, at least in part, the application under test and invoke it through the network. Unlike unit, component, and integration tests, which can be categorized as in-memory tests, API tests are out-of-process tests. We are no longer instantiating the unit under test directly in memory. This means we’re adding a new dependency into the mix: a network, as well as the deployment of some network service. Table 10.4 shows the scorecard for API tests.
在端到端隔离级别(E2E)和用户界面(UI)测试,我们从用户的角度测试我们的应用程序。我使用“隔离”一词来指定我们仅测试我们自己的应用程序或服务,而不部署我们的应用程序可能需要的任何依赖应用程序或服务。此类测试会伪造第三方身份验证机制、需要部署在同一服务器上的其他应用程序的 API,以及任何不属于被测主应用程序的特定代码(包括来自同一组织其他部门的应用程序)这些也会被伪造)。
At the level of isolated end-to-end (E2E) and user interface (UI) tests, we are testing our application from the point of view of a user. I use the word isolated to specify that we are testing only our own application or service, without deploying any dependency applications or services that our application might need. Such tests fake third-party authentication mechanisms, the APIs of other applications that are required to be deployed on the same server, and any code that is not specifically a part of the main application under test (including apps from the same organization’s other departments—those would be faked as well).
Table 10.5 shows the scorecard for E2E/UI isolated tests.
Table 10.5 E2E/UI isolated test scorecard
在系统 E2E 和 UI 测试层面,没有什么是假的。这与我们所能得到的生产部署非常接近:所有依赖应用程序和服务都是真实的,但它们可能会进行不同的配置以允许我们的测试场景。表 10.6 显示了 E2E/UI 系统测试的记分卡。
At the level of system E2E and UI tests nothing is fake. This is as close to a production deployment as we can get: all dependency applications and services are real, but they might be differently configured to allow for our testing scenarios. Table 10.6 shows the scorecard for E2E/UI system tests.
Table 10.6 E2E/UI system test scorecard
测试级反模式本质上不是技术性的,而是组织性的。您可能亲眼见过它们。作为顾问,我可以告诉你,它们非常普遍。
Test-level antipatterns are not technical but organizational in nature. You’ve likely seen them firsthand. As a consultant, I can tell you that they are very prevalent.
组织的一个非常常见的策略是主要使用(如果不是唯一的话)E2E 测试(隔离测试和系统测试)。图 10.2 显示了测试级别和类型图中的情况。
A very common strategy that an organization will have is using mostly, if not only, E2E tests (both isolated and system tests). Figure 10.2 shows what this looks like in the diagram of test levels and types.
Figure 10.2 End-to-end-only test antipattern
为什么这是反模式?这个级别的测试非常慢,难以维护,难以调试,而且非常不稳定。这些成本保持不变,而您从每个新的 E2E 测试中获得的价值却在减少。
Why is this an antipattern? Tests at this level are very slow, hard to maintain, hard to debug, and very flaky. These costs remain the same, while the value you get from each new E2E test diminishes.
Diminishing returns from E2E tests
您编写的第一个 E2E 测试将为您带来最大的信心,因为该场景中包含了许多其他代码路径,并且因为粘合剂(编排应用程序和其他系统之间的工作的代码)被调用为该测试的一部分。
The first E2E test you write will bring you the most confidence because of how many other paths of code are included as part of that scenario, and because of the glue—the code orchestrating the work between your application and other systems—that gets invoked as part of that test.
但是第二个E2E测试呢?它通常是第一次测试的变化,这意味着它可能只会带来相同值的一小部分。也许组合框和其他 UI 元素有所不同,但所有依赖项(例如数据库和第三方系统)保持不变。
But what about the second E2E test? It will usually be a variation on the first test, which means it might only bring a small fraction of the same value. Maybe there’s a difference in a combo box and other UI elements, but all the dependencies, such as the database and third-party systems, remain the same.
您从第二次E2E 测试中获得的额外置信度也只是您从第一次 E2E 测试中获得的额外置信度的一小部分。然而,调试、更改、读取和运行该测试的成本并不是一小部分;与之前的测试基本相同。为了一点额外的信心,你需要付出大量的额外工作,这就是为什么我喜欢说 E2E 测试的回报很快就会递减。
The amount of extra confidence you get from the second E2E test is also only a fraction of the extra confidence you got from the first E2E test. However, the cost of debugging, changing, reading, and running that test is not a fraction; it is basically the same as for the previous test. You’re incurring a lot of extra work for a very small bit of extra confidence, which is why I like to say that E2E tests have quickly diminishing returns.
如果我想要第一次测试有变化,那么在比前一次测试更低的水平上进行测试会更加务实。从第一次测试开始,我就已经知道了大多数(如果不是全部)层间粘合的作用。如果我能够以较低的水平证明下一个场景,并且在几乎相同的信心下支付更少的费用,那么就没有必要支付另一次 E2E 测试的税。
If I want variation on the first test, it would be much more pragmatic to test at a lower level than the previous test. I already know most, if not all, of the glue between layers works, from the first test. There’s no need to pay the tax of another E2E test if I can prove the next scenario at a lower level and pay a much smaller fee for pretty much the same bit of confidence.
通过端到端测试,我们不仅收益递减,而且在组织中造成了新的瓶颈。由于高级测试通常很不稳定,因此它们会因许多不同的原因而失败,其中一些原因与测试无关。然后,您需要组织中的特殊人员(通常是 QA 领导)坐下来分析许多失败的测试中的每一个,并找出原因并确定它是否确实是一个问题或一个小问题。
With E2E tests, not only do we have diminishing returns, we create a new bottleneck in the organization. Because high-level tests are often flaky, they break for many different reasons, some of which are not relevant to the test. You then need special people in the organization (usually QA leads) to sit down and analyze each of the many failing tests, and to hunt down the cause and determine if it’s actually a problem or a minor issue.
我称这些可怜的灵魂为“低语者”。当构建为红色时(大多数情况下都是如此),构建耳语者必须进来,解析数据,并在经过数小时的检查后有意识地说,“是的,它看起来是红色的,但实际上是绿色的。”
I call these poor souls build whisperers. When the build is red, which it is most of the time, build whisperers are the ones who must come in, parse the data, and knowingly say, after hours of inspection, “Yes, it looks red, but it’s actually green.”
通常,该组织会将构建低语者逼到角落,要求他们说构建是绿色的,因为“我们必须将此版本发布出去”。他们是发布的看门人,这是一项吃力不讨好、压力很大、而且常常是体力劳动且令人沮丧的工作。窃窃私语者通常会在一两年内精疲力竭,他们会被咀嚼并被吐到下一个组织,在那里他们会再次做同样吃力不讨好的工作。当许多高级 E2E 测试的这种反模式存在时,您经常会看到构建耳语者。
Usually, the organization will drive build whisperers into a corner, demanding that they say the build is green because “We have to get this release out the door.” They are the gatekeepers of the release, and that is a thankless, stressful, and often manual and frustrating job. Whisperers usually burn out within a year or two, and they get chewed up and spit out into the next organization, where they do the same thankless job all over again. You’ll often see build whisperers when this antipattern of many high-level E2E tests exists.
有一种方法可以解决这个混乱,那就是创建和培养强大的自动化测试管道,即使您的测试不可靠,也可以自动判断构建是否是绿色的。Netflix 公开发布了博客,介绍如何创建自己的工具来衡量构建在野外的统计表现,以便可以自动批准其进行完整发布部署 (http://mng.bz/BAA1)。这是可行的,但需要时间和文化来实现这样的管道。我在我的博客 https://pipelinedriven.org 中撰写了有关这些类型管道的更多信息。
There is a way to resolve this mess, and that’s to create and cultivate robust, automated test pipelines that can automatically judge whether a build is green or not, even if you have flaky tests. Netflix has openly blogged about creating their own tool for measuring how a build is doing statistically in the wild, so that it can be automatically approved for full release deployment (http://mng.bz/BAA1). This is doable, but it takes time and culture to achieve such a pipeline. I write more about these types of pipelines in my blog at https://pipelinedriven.org.
A “throw it over the wall” mentality
仅进行 E2E 测试对组织造成伤害的另一个原因是,负责维护和监控这些测试的人员是 QA 部门的人员。这意味着组织的开发人员可能不关心甚至不知道这些构建的结果,并且他们不会投资于修复或关心这些测试。他们不拥有它们。
Another reason having only E2E tests hurts organizations is that the people in charge of maintaining and monitoring these tests are people in the QA department. This means that the organization’s developers might not care about or even know the results of these builds, and they are not invested in fixing or caring for these tests. They don’t own them.
这种“把事情扔到墙上”的心态可能会导致许多沟通不畅和质量问题,因为组织的一方与其行为的后果无关,而另一方则在无法控制行为来源的情况下承受后果。问题。在许多组织中,开发人员和 QA 人员相处不好,这有什么奇怪的吗?他们周围的制度往往被设计成让他们成为不共戴天的敌人而不是合作者。
This “throw it over the wall” mentality can cause lots of miscommunication and quality issues because one part of the organization is not connected to the consequences of its actions, and the other side is suffering the consequences without being able to control the source of the issue. Is it any wonder that, in many organizations, developers and QA people don’t get along? The system around them is often designed to make them mortal enemies instead of collaborators.
These are some reasons why I see this happen:
职责分离——许多组织中都存在具有独立管道(自动构建作业和仪表板)的独立 QA 和开发部门。当 QA 部门拥有自己的管道时,它可能会编写更多同类测试。此外,质量保证部门倾向于只编写特定类型的测试 - 他们习惯并期望编写的测试(有时基于公司政策)。
Separation of duties—Separate QA and development departments with separate pipelines (automated build jobs and dashboards) exist in many organizations. When a QA department has its own pipeline, it is likely to write more tests of the same kind. Also, a QA department tends to write only a specific type of test—the ones they’re used to and are expected to write (sometimes based on company policy).
“如果有效,就不要改变它”的心态——团队可能会从端到端测试开始,并看到他们喜欢结果。他们继续以相同的方式添加所有新测试,因为这是他们所知道的,并且已被证明是有用的。当运行测试所需的时间变得太长时,改变方向就已经太晚了(这与下一点有关)。
An “if it works, don’t change it” mentality—A group might start with E2E tests and see that they like the results. They continue to add all their new tests in the same way, because it’s what they know, and it has proven to be useful. When the time it takes to run tests gets too long, it’s already too late to change direction (which relates to the next point).
沉没成本谬误——“我们有很多此类测试,如果我们更改它们或用较低级别的测试替换它们,则意味着我们在要删除的测试上浪费了所有时间和精力。” 这是一个谬论,因为维护、调试和理解测试失败会花费大量的人力时间。如果有什么不同的话,那就是删除此类测试(仅保留一些基本场景)并收回时间的成本更低。
Sunk-costs fallacy—“We have lots of these types of tests, and if we changed them or replaced them with lower-level tests, it would mean we’ve wasted all that time and effort on tests that we are removing.” This is a fallacy, because maintaining, debugging, and understanding test failures costs a fortune in human time. If anything, it costs less to delete such tests (keeping only a few basic scenarios) and get that time back.
Should you avoid E2E tests completely?
不,我们无法避免端到端测试。好的之一他们提供的是对应用程序运行的信心。与单元测试相比,这是完全不同的置信度,因为它们从用户的角度测试整个系统及其所有子系统和组件的集成。当它们过去时,您会感到非常轻松,因为您期望用户遇到的主要场景确实有效。
No, we can’t avoid E2E tests. One of the good things they offer is confidence that the application works. It’s a completely different level of confidence compared to unit tests, because they test the integration of the full system, with all of its subsystems and components, from the point of view of a user. When they pass, the feeling you get is huge relief that the major scenarios you expect your users to encounter actually work.
所以不要完全避开它们。相反,我强烈建议尽量减少E2E 测试的数量。我们将在第 10.3.3 节中讨论该最小值是多少。
So don’t avoid them entirely. Instead, I highly recommend minimizing the number of E2E tests. We’ll talk about what that minimum is in section 10.3.3.
与仅进行 E2E 测试相反的是仅进行低级测试。单元测试提供快速反馈,但它们无法提供完全信任您的应用程序作为单个集成单元运行所需的信心(见图 10.3)。
The opposite of having only E2E tests is to have low-level tests only. Unit tests provide fast feedback, but they don’t provide the amount of confidence needed to fully trust that your application works as a single integrated unit (see figure 10.3).
Figure 10.3 Low-level-only test antipattern
在这种反模式中,组织的自动化测试主要或完全是低级测试,例如单元测试或组件测试。可能有集成测试的迹象,但目前还没有端到端测试。
In this antipattern, the organization’s automated tests are mostly or exclusively low-level tests, such as unit tests or component tests. There may be hints of integration tests, but there are no E2E tests in sight.
最大的问题是,当这些类型的测试通过时,您获得的置信度不足以让您对应用程序的工作充满信心。这意味着人们将运行测试,然后继续进行手动调试和测试,以获得发布某些内容所需的最终信心。除非您要交付的是一个代码库,该代码库旨在以单元测试使用它的方式使用,否则这还不够。是的,测试将运行得很快,但您仍然会花费大量时间手动测试和验证。
The biggest issue with this is that the confidence level you get when these types of tests pass is simply not enough to feel confident that your application works. That means people will run the tests and then continue to do manual debugging and testing to get the final sense of confidence needed to release something. Unless what you’re shipping is a code library that’s meant to be used in the way your unit tests are using it, this won’t be enough. Yes, the tests will run quickly, but you’ll still spend lots of time manually testing and verifying.
当您的开发人员只习惯编写低级测试(如果他们感觉不舒服)时,通常会发生这种反模式编写高级测试,或者他们希望 QA 人员编写这些类型的测试。
This antipattern often happens when your developers are only used to writing low-level tests, if they don’t feel comfortable writing high-level tests, or if they expect the QA people to write those types of tests.
这是否意味着您应该避免单元测试?很明显不是。但我强烈建议您不仅进行单元测试,还进行更高级别的测试。我们将在 10.3 节中讨论该建议。
Does that mean you should avoid unit tests? Obviously not. But I highly recommend that you have not only unit tests but also higher-level tests. We’ll discuss this recommendation in section 10.3.
这种模式乍一看似乎很健康,但事实并非如此。它可能看起来有点像图 10.4。
This pattern might seem healthy at first, but it really isn’t. It might look a bit like figure 10.4.
Figure 10.4 Disconnected low-level and high-level tests
是的,您想要同时进行低级测试(为了速度)和高级测试(为了信心)。但是,当您在组织中看到类似的情况时,您可能会遇到以下一种或多种反行为:
Yes, you want to have both low-level tests (for speed) and high-level tests (for confidence). But when you see something like this in an organization, you will likely encounter one or more of these anti-behaviors:
编写低级测试的人与编写高级测试的人不同。这意味着他们不关心彼此的测试结果,并且他们可能会使用不同的管道执行不同的测试类型。当一个管道呈红色时,另一组可能甚至不知道也不关心这些测试失败。
The people who write the low-level tests are not the same people who write the high-level tests. This means they don’t care about each other’s test results, and they’ll likely have different pipelines execute the different test types. When one pipeline is red, the other group might not even know nor care that those tests are failing.
我们遭受着两全其美的痛苦:在顶层,我们面临着测试时间长、可维护性困难、构建低语者和不稳定的问题;在底层,我们缺乏信心。而且由于经常缺乏沟通,我们无法获得低级测试的速度优势,因为它们无论如何都会在顶部重复。我们也没有得到最高级别的信心,因为如此大量的测试是多么不稳定。
We suffer the worst of both worlds: at the top level, we suffer from the long test times, difficult maintainability, build whisperers, and flakiness; at the bottom level, we suffer from lack of confidence. And because there is often a lack of communication, we don’t get the speed benefit of the low-level tests because they repeat at the top anyway. We also don’t get the top-level confidence because of how flaky such a large number of tests is.
当我们有不同的目标和指标,以及不同的作业和管道、权限甚至代码存储库的单独的测试和开发组织时,通常会发生这种模式。公司越大,这种情况发生的可能性就越大。
This pattern often happens when we have separate test and a development organizations with different goals and metrics, as well as different jobs and pipelines, permissions, and even code repositories. The larger the company, the more likely this is to happen.
我提出的实现组织使用的测试类型平衡的策略是使用测试配方。这个想法是制定一个关于如何测试特定功能的非正式计划。该计划不仅应包括主要场景(也称为“快乐路径”),还应包括其所有显着变化(也称为“边缘情况”),如图 10.5 所示。概述良好的测试方案可以清楚地说明适合每种场景的测试级别。
My proposed strategy to achieve balance in the types of tests used by the organization is to use test recipes. The idea is to have an informal plan for how a particular feature is going to be tested. This plan should include not only the main scenario (also known as the happy path), but also all its significant variations (also known as edge cases), as shown in figure 10.5. A well-outlined test recipe gives a clear picture of what test level is appropriate for each scenario.
图 10.5 测试配方是一个测试计划,概述了应在哪个级别测试特定功能。
Figure 10.5 A test recipe is a test plan, outlining at which level a particular feature should be tested.
最好至少有两个人创建一个测试配方 - 希望一个人具有开发人员的观点,另一个人具有测试人员的观点。如果没有测试部门,两个开发人员,或者一个开发人员加一个高级开发人员就足够了。将每个场景映射到测试层次结构中的特定级别可能是一项高度主观的任务,因此两双眼睛将有助于检查彼此的隐含假设。
It’s best to have at least two people create a test recipe—hopefully one with a developer’s point of view and one with a tester’s point of view. If there is no test department, two developers, or a developer with a senior developer will suffice. Mapping each scenario to a specific level in the test hierarchy can be a highly subjective task, so two pairs of eyes will help keep each other’s implicit assumptions in check.
食谱本身可以作为额外文本存储在 TODO 列表中,或者作为任务跟踪板上的专题故事的一部分。您不需要单独的工具来规划测试。
The recipes themselves can be stored as extra text in a TODO list or as part of the feature story on the tracking board for the task. You don’t need a separate tool for planning tests.
创建测试配方的最佳时间是在开始开发该功能之前。这样,测试配方就成为该功能“完成”定义的一部分,这意味着在完整的测试配方通过之前该功能尚未完成。
The best time to create a test recipe is just before you start working on the feature. This way, the test recipe becomes part of the definition of “done” for the feature, meaning the feature is not complete until the full test recipe is passing.
当然,食谱可能会随着时间的推移而改变。团队可以从中添加或删除场景。配方不是一个严格的工件,而是一个持续进行中的工作,就像软件开发中的其他一切一样。
Of course, a recipe can change as time goes by. The team can add or remove scenarios from it. A recipe is not a rigid artifact but a continuous work in progress, just like everything else in software development.
测试配方代表了一系列场景,这些场景将让其创建者对该功能的工作“非常有信心”。根据经验,我喜欢测试级别之间的比例为 1:5 或 1:10。对于任何高级别的 E2E 测试,我可能会进行 5 个较低级别的测试。或者,如果您自下而上思考,则假设您有 100 个单元测试。您通常不需要进行超过 10 个集成测试和 1 个 E2E 测试。
A test recipe represents the list of scenarios that will give its creators “pretty good confidence” that the feature works. As a rule of thumb, I like to have a 1 to 5 or 1 to 10 ratio between levels of tests. For any high-level, E2E test, I might have 5 tests at a lower level. Or, if you think bottom-up, say you have 100 unit tests. You usually won’t need to have more than 10 integration tests and 1 E2E test.
不过,不要将测试食谱视为正式的东西。测试配方不是具有约束力的承诺,也不是测试计划软件中的测试用例列表。请勿将其用作公开报告、用户故事或对利益相关者的任何其他类型的承诺。菜谱的核心是一个由 5 到 20 行文本组成的简单列表,详细说明了要以自动化方式进行测试以及测试级别的简单场景。该列表可以更改、添加或删除。将其视为评论。我通常喜欢将其直接放在 Jira 或我正在使用的任何程序的用户故事或功能中。
Don’t treat test recipes as something formal, though. A test recipe is not a binding commitment or a list of test cases in a test-planning piece of software. Don’t use it as a public report, a user story, or any other kind of promise to a stakeholder. At its core, a recipe is a simple list of 5 to 20 lines of text detailing simple scenarios to be tested in an automated fashion and at what level. The list can be changed, added to, or subtracted from. Consider it a comment. I usually like to just put it right in the user story or feature in Jira or whatever program I’m using.
Here’s an example of what one might look like:
用户个人资料功能测试秘诀 E2E - 登录、转到个人资料屏幕、更新电子邮件、注销、使用新电子邮件登录、验证个人资料屏幕已更新 API - 使用更复杂的数据调用 UpdateProfile API 单元测试 - 使用错误电子邮件检查个人资料更新逻辑 单元测试 - 使用同一电子邮件的配置文件更新逻辑 单元测试 - 配置文件序列化/反序列化
User profile feature testing recipe E2E - Login, go to profile screen, update email, log out, log in with new email, verify profile screen updated API - Call UpdateProfile API with more complicated data Unit test - Check profile update logic with bad email Unit test - Profile update logic with same email Unit test - Profile serialization/deserialization
在开始编写功能或用户故事之前,与另一个人坐下来尝试想出各种要测试的场景。讨论应该在哪个级别上最好地测试该场景。这次会议通常不会超过 5 到 15 分钟,之后就开始编码,包括编写测试。(如果您正在进行 TDD,您将从测试开始。)
Just before you start coding a feature or a user story, sit down with another person and try to come up with various scenarios to be tested. Discuss at which level that scenario should be best tested. This meeting will usually be no longer than 5 to 15 minutes, and after it, coding begins, including the writing of the tests. (If you’re doing TDD, you’ll start with the tests.)
在具有自动化或 QA 角色的组织中,开发人员将编写较低级别的测试,而 QA 将专注于编写较高级别的测试,同时进行功能编码。两个人同时工作。一个人不会等待另一个人完成工作才开始编写测试。
In organizations where there are automation or QA roles, the developer will write the lower-level tests, and the QA will focus on writing the higher-level tests, while coding of the feature is taking place. Both people are working at the same time. One does not wait for the other to finish their work before starting to write their tests.
如果您正在使用功能切换,则还应该将它们作为测试的一部分进行检查,以便如果某个功能关闭,则其测试将不会运行。
If you are working with feature toggles, they should also be checked as part of the tests, so that if a feature is off, its tests will not run.
There are several rules to follow when writing a test recipe:
Faster—Prefer writing tests at lower levels, unless a high-level test is the only way for you to gain confidence that the feature works.
信心——当你可以告诉自己“如果所有这些测试都通过了,我会对这个功能的工作感觉非常好”时,配方就完成了。如果你不能这么说,那就写更多能让你这么说的场景。
Confidence—The recipe is done when you can tell yourself, “If all these tests passed, I’ll feel pretty good about this feature working.” If you can’t say that, write more scenarios that will allow you to say that.
Revise—Feel free to add or remove tests from the list as you code. Just make sure you notify the other person you worked with on the recipe.
Just in time—Write this recipe just before starting to code, when you know who is going to code it.
Pair—Don’t write it alone if you can help it. People think in different ways, and it’s important to talk through the scenarios and learn from each other about testing ideas and mindset.
Don’t repeat yourself from other features—If this scenario is already covered by an existing test (perhaps an E2E test from a previous feature), there is no need to repeat this scenario at that level.
不要在其他层重复自己——尽量不要在多个层重复相同的场景。如果您在 E2E 级别检查是否成功登录,则较低级别的测试应该仅检查该场景的变体(使用不同的提供商登录、登录不成功的结果等)。
Don’t repeat yourself from other layers—Try not to repeat the same scenario at multiple levels. If you’re checking a successful login at the E2E level, lower-level tests should only check variations of that scenario (logging in with different providers, unsuccessful login results, etc.).
更多、更快— 一个好的经验法则是最终级别之间的比例至少为 1 比 5(对于一个 E2E 测试,您可能最终会进行 5 个或更多较低级别的测试)。
More, faster—A good rule of thumb is to end up with a ratio of at least one to five between levels (for one E2E test, you might end up with five or more lower-level tests).
务实——不需要为给定的功能编写所有级别的测试。某些功能或用户故事可能只需要单元测试。其他的,只有API或E2E测试。基本思想是,如果配方中的所有场景都通过了,那么无论测试的级别如何,您都应该感到有信心。如果情况并非如此,请将场景移动到不同的级别,直到您感到更加自信,而不会牺牲太多的速度或维护负担。
Pragmatic—Don’t feel the need to write tests at all levels for a given feature. Some features or user stories might only require unit tests. Others, only API or E2E tests. The basic idea is that, if all the scenarios in the recipe pass, you should feel confidence, regardless of what level they are tested at. If that’s not the case, move the scenarios around to different levels until you feel more confident, without sacrificing too much speed or maintenance burden.
通过遵循这些规则,您将获得快速反馈的好处,因为您的大多数测试都是低级别的,同时不会牺牲信心,因为少数最重要的场景仍然由高级测试覆盖。测试配方方法还允许您通过将场景变化定位在低于主场景的级别来避免测试之间的大部分重复。最后,如果 QA 人员也参与编写测试方案,您将在组织内的人员之间形成新的沟通渠道,这有助于增进对软件项目的相互理解。
By following these rules, you’ll get the benefit of fast feedback, because most of your tests will be low level, while not sacrificing confidence because the few most important scenarios are still covered by high-level tests. The test recipe approach also allows you to avoid most of the repetition between tests by positioning scenario variations at levels lower than the main scenario. Finally, if QA people are involved in writing test recipes too, you’ll form a new communication channel between people within your organization, which helps improve mutual understanding of your software project.
性能测试怎么样?安全测试?负载测试?那么许多其他可能需要很长时间才能运行的测试呢?我们应该在何时何地运行它们?它们是哪一层?它们应该成为我们自动化管道的一部分吗?
What about performance tests? Security tests? Load tests? What about lots of other tests that might take ages to run? Where and when should we run them? Which layer are they? Should they be part of our automated pipeline?
许多组织将这些测试作为针对每个版本或拉取请求运行的集成自动化管道的一部分来运行。然而,这会导致反馈的巨大延迟,并且反馈经常“失败”,即使失败对于发布此类测试的版本来说并不是必需的。
Lots of organizations run those tests as part of the integration automated pipeline that runs for each release or pull request. However, this causes huge delays in feedback, and the feedback is often “failed,” even though the failure is not essential for a release to go out for these types of tests.
We can divide these types of tests into two main groups:
交付阻塞测试——这些测试为即将发布和部署的变更提供是否进行的决定。单元、端到端、系统和安全测试都属于这一类。他们的反馈是二元的:他们要么通过并宣布更改没有引入任何错误,要么失败并表明代码需要在发布之前修复。
Delivery-blocking tests—These are tests that provide a go or no-go for the change that is about to be released and deployed. Unit, E2E, system, and security tests all fall into this category. Their feedback is binary: they either pass and announce that the change didn’t introduce any bugs, or they fail and indicate that the code needs to be fixed before it’s released.
值得了解的测试——这些测试是为了发现和持续监控关键绩效指标 (KPI) 指标而创建的。示例包括代码分析和复杂性扫描、高负载性能测试以及提供非二进制反馈的其他长时间运行的非功能测试。如果这些测试失败,我们可能会在下一个冲刺中添加新的工作项目,但我们仍然可以发布我们的软件。
Good-to-know tests—These are tests created for the purpose of discovery and continuous monitoring of key performance indicator (KPI) metrics. Examples include code analysis and complexity scanning, high-load performance testing, and other long-running nonfunctional tests that provide nonbinary feedback. If these tests fail, we might add new work items to our next sprints, but we would still be OK releasing our software.
我们不希望我们的众所周知的测试从我们的交付过程中占用宝贵的反馈时间,因此我们还将有两种类型的管道:
We don’t want our good-to-know tests to take valuable feedback time from our delivery process, so we’ll also have two types of pipelines:
交付管道——用于交付阻塞测试。当管道变绿时,我们应该相信我们可以自动将代码发布到生产环境。该管道中的测试应该提供相对快速的反馈。
Delivery pipeline—Used for delivery-blocking tests. When the pipeline is green, we should be confident that we can automatically release the code to production. Tests in this pipeline should provide relatively fast feedback.
发现管道——用于众所周知的测试。该管道与交付管道并行运行,但持续运行,并且不将其视为发布标准。由于无需等待其反馈,因此此管道中的测试可能需要很长时间。如果发现错误,它们可能会成为团队下一个冲刺中的新工作项,但发布不会被阻止。
Discovery pipeline—Used for good-to-know tests. This pipeline runs in parallel with the delivery pipeline, but continuously, and it’s not taken into account as a release criterion. Since there’s no need to wait for its feedback, tests in this pipeline can take a long time. If errors are found, they might become new work items in the next sprints for the team, but releases are not blocked.
Figure 10.6 illustrates the features of these two kinds of pipelines.
Figure 10.6 Delivery vs. discovery pipelines
交付管道的重点是提供一个通过/不通过检查,如果一切看起来都是绿色的,甚至可能部署到生产环境,该检查也会部署我们的代码。发现管道的重点是为团队提供重构目标,例如处理变得太高的代码复杂性。它还可以显示这些重构工作随着时间的推移是否有效。除了运行专门测试或分析代码及其各种 KPI 指标的目的之外,发现管道不会部署任何内容。它以仪表板上的数字结尾。
The point of the delivery pipeline is to provide a go/no-go check that also deploys our code if all seems green, perhaps even to production. The point of the discovery pipeline is to provide refactoring objectives for the team, such as dealing with code complexity that has become too high. It can also show whether those refactoring efforts are effective over time. The discovery pipeline does not deploy anything except for the purpose of running specialized tests or analyzing code and its various KPI metrics. It ends with numbers on a dashboard.
速度是让团队更加投入的一个重要因素,将测试分为发现和交付管道是您的武器库中的另一种技术。
Speed is a big factor in getting teams to be more engaged, and splitting tests into discovery and delivery pipelines is yet another technique to keep in your arsenal.
由于快速反馈非常重要,因此在许多场景中您可以并且应该采用的常见模式是并行运行不同的测试层以加速管道反馈,如图 10.7 所示。您甚至可以使用动态创建并在测试结束时销毁的并行环境。
Since fast feedback is very important, a common pattern you can and should employ in many scenarios is to run different test layers in parallel to speed up the pipeline feedback, as shown in figure 10.7. You can even use parallel environments that are created dynamically and destroyed at the end of the test.
图 10.7 为了加快交付速度,您可以并行运行管道,甚至管道中的阶段。
Figure 10.7 To speed up delivery, you can run pipelines, and even stages in pipelines, in parallel.
这种方法受益于动态环境的访问。在环境和自动化并行测试上投入资金几乎总是比投入资金让更多人进行更多手动测试,或者只是让人们等待更长时间才能获得反馈(因为环境正在使用)要有效得多。
This approach benefits greatly from having access to dynamic environments. Throwing money at environments and automated parallel tests is almost always much more effective than throwing money at more people to do more manual tests, or simply having people wait longer to get feedback because the environment is being used right now.
手动测试是不可持续的,因为这种手动工作只会随着时间的推移而增加,并且变得越来越脆弱且容易出错。与此同时,仅仅等待更长时间的管道反馈就会给每个人带来巨大的时间浪费。等待时间乘以等待的人数和每天的构建数量,得出的每月投资可能比动态环境和自动化的投资要大得多。获取一个 Excel 文件并向您的经理展示一个简单的公式来计算预算。
Manual testing is unsustainable because such manual work only increases over time and becomes more and more frail and error prone. At the same time, simply waiting longer for pipeline feedback results in a huge waste of time for everyone. The waiting time, multiplied by the number of people waiting and the number of builds per day, results in a monthly investment that can be much larger than investing in dynamic environments and automation. Grab an Excel file and show your manager a simple formula to get that budget.
您不仅可以并行化管道内的阶段,还可以并行化管道内的各个阶段。您还可以进一步并行运行单独的测试。例如,如果您遇到大量 E2E 测试,您可以将它们分解为并行测试套件。这可以节省反馈循环的大量时间。
You can parallelize not only stages inside a pipeline; you can go further and run individual tests in parallel too. For example, if you’re stuck with a large number of E2E tests, you can break them up into parallel test suites. That shaves a lot of time off your feedback loop.
有多个级别的测试:在内存中运行的单元测试、组件测试和集成测试;以及 API、隔离的端到端 (E2E) 以及在进程外运行的系统 E2E 测试。
There are multiple levels of tests: unit, component, and integration tests that run in memory; and API, isolated end-to-end (E2E), and system E2E tests that run out of process.
Each test can be judged by five criteria: complexity, flakiness, confidence when it passes, maintainability, and execution speed.
单元和组件测试在可维护性、执行速度以及缺乏复杂性和脆弱性方面是最好的,但在提供的可信度方面却是最差的。集成和 API 测试是信心与其他指标之间权衡的中间立场。E2E 测试采用与单元测试相反的方法:它们提供最佳的置信度,但代价是可维护性、速度、复杂性和脆弱性。
Unit and component tests are best in terms of maintainability, execution speed, and lack of complexity and flakiness, but they’re worst in terms of the confidence they provide. Integration and API tests are the middle ground in the trade-off between confidence and the other metrics. E2E tests take the opposite approach from unit tests: they provide the best confidence but at the expense of maintainability, speed, complexity, and flakiness.
仅端到端反模式是指您的构建仅包含 E2E 测试。每次额外的E2E测试的边际价值很低,而所有测试的维护成本是相同的。如果您只需进行一些涵盖最重要功能的 E2E 测试,您的努力就会获得最大回报。
The end-to-end-only antipattern is when your build consists solely of E2E tests. The marginal value of each additional E2E test is low, while the maintenance costs of all tests are the same. You’ll get the most return on your efforts if you have just a few E2E tests covering the most important functionality.
仅低级反模式是指您的构建仅包含单元和组件测试。较低级别的测试无法提供足够的信心来证明您的功能作为一个整体可以正常工作,并且必须用较高级别的测试来补充它们。
The low-level-only antipattern is when your build consists solely of unit and component tests. Lower-level tests can’t provide enough confidence that your functionality as a whole works, and they must be supplemented with higher-level tests.
断开的低级和高级测试是一种反模式,因为它强烈表明您的测试是由两组彼此不沟通的人编写的。此类测试经常相互重复并带来高昂的维护成本。
Disconnected low-level and high-level tests is an antipattern because it’s a strong sign that your tests are written by two groups of people who don’t communicate with each other. Such tests often duplicate each other and carry high maintenance costs.
测试配方是一个由 5 到 20 行文本组成的简单列表,详细说明了应该以自动化方式测试哪些简单场景以及在什么级别进行测试。测试方案应该让您确信,如果所有概述的测试都通过,则该功能将按预期工作。
A test recipe is a simple list of 5 to 20 lines of text, detailing which simple scenarios should be tested in an automated fashion and at what level. A test recipe should give you confidence that, if all outlined tests pass, the feature works as intended.
将构建管道分为交付管道和发现管道。交付管道应用于交付阻塞测试,如果失败,则停止交付被测代码。发现管道用于众所周知的测试,并与交付管道并行运行。
Split your build pipeline into delivery and discovery pipelines. The delivery pipeline should be used for delivery-blocking tests, which, if they fail, stop delivery of the code under test. The discovery pipeline is used for good-to-know tests and runs in parallel with the delivery pipeline.
You can parallelize not just pipelines but also stages inside those pipelines, and even groups of tests inside stages too.
作为一名顾问,我帮助多家大大小小的公司将持续交付流程和各种工程实践(例如测试驱动开发和单元测试)集成到他们的组织文化中。有时这种做法会失败,但那些成功的公司有几个共同点。在任何类型的组织中,改变人们的习惯更多的是心理上的而不是技术上的。人们不喜欢改变,而改变通常伴随着大量的 FUD(恐惧、不确定性和怀疑)。正如您将在本章中看到的那样,对于大多数人来说,这并不像在公园里散步那样轻松。
As a consultant, I’ve helped several companies, big and small, integrate continuous delivery processes and various engineering practices, such as test-driven development and unit testing, into their organizational culture. Sometimes this has failed, but those companies that succeeded had several things in common. In any type of organization, changing people’s habits is more psychological than technical. People don’t like change, and change is usually accompanied with plenty of FUD (fear, uncertainty, and doubt) to go around. It won’t be a walk in the park for most people, as you’ll see in this chapter.
如果您要成为组织中变革的推动者,您应该首先接受这个角色。人们会将你视为对正在发生的事情负责(有时是负责)的人,无论你是否希望他们这样做,隐藏是没有用的。事实上,隐藏可能会导致事情变得非常糟糕。
If you’re going to be the agent of change in your organization, you should first accept that role. People will view you as the person responsible (and sometimes accountable) for what’s happening, whether or not you want them to, and there’s no use in hiding. In fact, hiding can cause things to go terribly wrong.
当你开始实施或推动变革时,人们会开始提出与他们关心的问题相关的尖锐问题。这会“浪费”多少时间?作为一名 QA 工程师,这对我意味着什么?我们怎么知道它有效?准备好回答。第 11.5 节讨论了最常见问题的答案。您会发现,当您需要做出艰难的决定并回答这些问题时,在开始做出改变之前说服组织内部的其他人会对您有很大帮助。
As you start to implement or push for changes, people will start asking tough questions related to what they care about. How much time will this “waste”? What does this mean for me as a QA engineer? How do we know it works? Be prepared to answer. The answers to the most common questions are discussed in section 11.5. You’ll find that convincing others inside the organization before you start making changes will help you immensely when you need to make tough decisions and answer those questions.
最后,必须有人继续掌舵,确保变革不会因缺乏动力而失败。那是你。有一些方法可以让事物保持活力,正如您将在下一节中看到的那样。
Finally, someone will have to stay at the helm, making sure the changes don’t die for lack of momentum. That’s you. There are ways to keep things alive, as you’ll see in the next sections.
做你的研究。阅读本章末尾的问题和答案,并查看相关资源。阅读论坛、邮件列表和博客,并向您的同行咨询。如果你能回答自己的棘手问题,那么你就有很好的机会回答别人的问题。
Do your research. Read the questions and answers at the end of this chapter, and look at the related resources. Read forums, mailing lists, and blogs, and consult with your peers. If you can answer your own tough questions, there’s a good chance you can answer someone else’s.
在组织中,没有什么比逆流而行的决定更让你感到孤独的了。如果您是唯一认为自己正在做的事情是个好主意的人,那么任何人都没有理由努力实施您所倡导的事情。考虑谁可以帮助或损害你的努力:支持者和阻碍者。
Few things make you feel as lonely in an organization as the decision to go against the current. If you’re the only one who thinks what you’re doing is a good idea, there’s little reason for anyone to make an effort to implement what you’re advocating. Consider who can help and hurt your efforts: the champions and blockers.
当您开始推动变革时,请确定您认为最有可能为您的追求提供帮助的人。他们将成为你的冠军。他们通常是早期采用者,或者是思想开放的人,愿意尝试您所提倡的事情。他们可能已经半信半疑,但正在寻找开始改变的动力。他们甚至可能自己尝试过但失败了。
As you start pushing for change, identify the people you think are most likely to help in your quest. They’ll be your champions. They’re usually early adopters, or people who are open minded enough to try the things you’re advocating. They may already be half convinced but are looking for an impetus to start the change. They may have even tried it and failed on their own.
在其他人之前接近他们,询问他们对你要做的事情的意见。他们可能会告诉您一些您没有考虑过的事情,包括
Approach them before anyone else and ask for their opinions on what you’re about to do. They may tell you some things that you hadn’t considered, including
通过与他们接触,您可以帮助确保他们参与该流程。感觉自己是这个过程一部分的人通常会尽力帮助它发挥作用。让他们成为你的拥护者:询问他们是否可以帮助你,并成为人们可以向他们提出问题的人。让他们为此类事件做好准备。
By approaching them, you’re helping to ensure that they’re part of the process. People who feel part of the process usually try to help make it work. Make them your champions: ask them if they can help you and be the ones people can come to with questions. Prepare them for such events.
接下来,确定阻碍因素。这些人是组织中最有可能抵制你正在做出的改变的人。例如,经理可能反对添加单元测试,声称这会增加太多的开发时间并增加需要维护的代码量。通过让他们(至少是那些愿意并且有能力的人)在过程中发挥积极作用,使他们成为过程的一部分,而不是过程的抵制者。
Next, identify the blockers. These are the people in the organization who are most likely to resist the changes you’re making. For example, a manager might object to adding unit tests, claiming that they’ll add too much time to the development effort and increase the amount of code that needs to be maintained. Make them part of the process instead of resisters of it by giving them (at least those who are willing and able) an active role in the process.
人们抵制变革的原因各不相同。关于影响力的第 11.4 节涵盖了对一些可能反对意见的回答。有些人会担心工作保障,有些人会对目前的情况感到太舒服。接近潜在的阻碍者并详细说明他们可以做得更好的所有事情通常没有建设性,正如我经过艰难的方式发现的那样。人们不喜欢被告知他们的孩子很丑。
The reasons why people might resist changes vary. Answers to some of the possible objections are covered in section 11.4 on influence forces. Some will be worried about job security, and some will just feel too comfortable with the way things currently are. Approaching potential blockers and detailing all the things they could have done better is often not constructive, as I’ve found out the hard way. People don’t like to be told that their baby is ugly.
相反,请阻止者在此过程中帮助您,例如,负责定义单元测试的编码标准,或者每隔一天与同事一起进行代码和测试审查。或者让他们成为选择课程材料或外部顾问的团队的一部分。您将赋予他们新的责任,帮助他们在组织中感到被依赖和相关。他们需要成为变革的一部分,否则几乎肯定会破坏变革。
Instead, ask blockers to help you in the process by being in charge of defining coding standards for unit tests, for example, or by doing code and test reviews with peers every other day. Or make them part of the team that chooses the course materials or outside consultants. You’ll give them a new responsibility that will help them feel relied on and relevant in the organization. They need to be part of the change or they’ll almost certainly undermine it.
确定您可以在组织中的哪些位置开始实施变革。大多数成功的实施都采取稳定的路线。从一个小团队的试点项目开始,看看会发生什么。如果一切顺利,则继续进行其他团队和其他项目。
Identify where in the organization you can start implementing changes. Most successful implementations take a steady route. Start with a pilot project in a small team, and see what happens. If all goes well, move on to other teams and other projects.
Here are some tips that will help you along the way:
These tips can take you a long way in a mostly hostile environment.
确定可以开始的团队通常很容易。您通常会希望有一个小团队致力于低风险的低调项目。如果风险很小,就更容易说服人们尝试您提出的更改。
Identifying possible teams to start with is usually easy. You’ll generally want a small team working on a low-profile project with low risks. If the risk is minimal, it’s easier to convince people to try your proposed changes.
需要注意的是,团队需要有愿意改变工作方式和学习新技能的成员。讽刺的是,团队中经验较少的人通常最有可能乐于改变,而经验丰富的人往往更坚持自己的做事方式。如果您能找到一个团队,其领导者经验丰富,愿意接受变革,但也包括经验不足的开发人员,那么该团队很可能不会遇到什么阻力。到团队中询问他们对举办试点项目的意见。他们会告诉您这是否是正确的起点。
One caveat is that the team needs to have members who are open to changing the way they work and to learning new skills. Ironically, the people with less experience on a team are usually most likely to be open to change, and people with more experience tend to be more entrenched in their way of doing things. If you can find a team with an experienced leader who’s open to change, but that also includes less-experienced developers, it’s likely that team will offer little resistance. Go to the team and ask their opinion on holding a pilot project. They’ll tell you if this is (or is not) the right place to start.
试点测试的另一个可能的候选者是在现有团队内组建一个子团队。几乎每个团队都会有一个需要维护的“黑洞”组件,虽然它做了很多正确的事情,但它也存在很多错误。为这样的组件添加功能是一项艰巨的任务,这种痛苦会驱使人们尝试试点项目。
Another possible candidate for a pilot test is to form a subteam within an existing team. Almost every team will have a “black hole” component that needs to be maintained, and while it does many things right, it also has many bugs. Adding features for such a component is a tough task, and this kind of pain can drive people to experiment with a pilot project.
对于试点项目,请确保您不会贪多嚼不烂。运行更困难的项目需要更多的经验,因此您可能希望至少有两个选项 - 一个复杂的项目和一个更简单的项目 - 以便您可以在它们之间进行选择。
For a pilot project, make sure you’re not biting off more than you can chew. It takes more experience to run more difficult projects, so you might want to have at least two options—a complicated project and an easier project—so that you can choose between them.
Use code and test reviews as teaching tools
如果您是一个小团队(最多八人)的技术主管,最好的教学方法之一是进行代码审查,其中也包括测试审查。这个想法是,当您审查其他人的代码和测试时,您可以教他们您在测试中寻找什么以及您编写测试或接近 TDD 的思维方式。以下是一些提示:
If you’re the technical lead on a small team (up to eight people), one of the best ways of teaching is instituting code reviews that also include test reviews. The idea is that as you review other people’s code and tests, you teach them what you look for in the tests and your way of thinking about writing tests or approaching TDD. Here are some tips:
Do the reviews in person, not through remote software. The personal connection lets much more information pass between you in nonverbal ways, so learning happens better and faster.
In the first couple of weeks, review every line of code that gets checked in. This will help you avoid the “we didn’t think this code needs reviewing” problem.
在您的代码审查中添加第三人——他将坐在一边并了解您如何审查代码。这将使他们以后能够自己进行代码审查并指导其他人,这样您就不会成为团队的瓶颈,因为您是唯一有能力进行审查的人。这个想法是培养其他人进行代码审查并承担更多责任的能力。
Add a third person to your code reviews—one who will sit on the side and learn how you review the code. This will allow them to later do code reviews themselves and teach others, so that you won’t become a bottleneck for the team as the only person capable of doing reviews. The idea is to develop others’ ability to do code reviews and accept more responsibility.
如果您想了解有关此技术的更多信息,我在为技术领导者撰写的博客中写了相关内容:“良好的代码审查应该是什么样子?” 在https://5whys.com/blog/what-should-a-good-code-review-look-and-feel-like.xhtml。
If you want to learn more about this technique, I wrote about it in my blog for technical leaders: “What Should a Good Code Review Look and Feel Like?” at https://5whys.com/blog/what-should-a-good-code-review-look-and-feel-like.xhtml.
组织或团队可以通过两种主要方式开始改变流程:自下而上或自上而下(有时两者兼而有之)。正如您将看到的,这两种方法非常不同,并且任何一种方法都可能适合您的团队或公司。没有一种方法是正确的。
There are two main ways an organization or team can start changing a process: from the bottom-up or the top-down (and sometimes both). The two ways are very different, as you’ll see, and either could be the right approach for your team or company. There’s no one right way.
当你继续前进时,你需要学习如何说服管理层,你的努力也应该是他们的努力,或者何时引入外部人员提供帮助是明智的。让进展可见很重要,制定可衡量的明确目标也很重要。识别和避免障碍也应该是你的首要任务。可以进行的战斗有很多,你需要选择正确的战斗。
As you proceed, you’ll need to learn how to convince management that your efforts should also be their efforts, or when it would be wise to bring in someone from outside to help. Making progress visible is important, as is setting clear goals that can be measured. Identifying and avoiding obstacles should also be high on your list. There are many battles that can be fought, and you need to choose the right ones.
游击式实施就是从一个团队开始,取得成果,然后才让其他人相信这些做法是值得的。通常,游击式实施的驱动力是一个厌倦了按规定方式做事的团队。他们开始采取不同的做法;他们自己学习并做出改变。当团队显示结果时,组织中的其他人可能会决定开始在自己的团队中实施类似的变革。
Guerrilla-style implementation is all about starting out with a team, getting results, and only then convincing other people that the practices are worthwhile. Usually the driver for guerrilla implementation is a team who’s tired of doing things the prescribed way. They set out to do things differently; they study on their own and make changes happen. When the team shows results, other people in the organization may decide to start implementing similar changes in their own teams.
在某些情况下,游击式实施是首先由开发人员采用,然后由管理层采用的过程。有时,这是一个首先由开发人员倡导,然后由管理层倡导的过程。不同之处在于,你可以秘密地完成第一个任务,而不需要更高的权力知道。后者是与管理层一起完成的。由您决定哪种方法更有效。有时改变事情的唯一方法是秘密行动。如果可以的话,请避免这种情况,但如果没有其他方法,并且您确定需要进行更改,则可以这样做。
In some cases, guerrilla-style implementation is a process adopted first by developers and then by management. At other times, it’s a process advocated for first by developers and then by management. The difference is that you can accomplish the first covertly, without the higher powers knowing about it. The latter is done in conjunction with management. It’s up to you to figure out which approach will work better. Sometimes the only way to change things is by covert operations. Avoid this if you can, but if there’s no other way, and you’re sure the change is needed, you can just do it.
不要将此视为做出限制职业生涯的建议。开发人员总是在未经许可的情况下做事:调试代码、阅读电子邮件、编写代码注释、创建流程图等等。这些都是开发人员日常工作中要做的任务。单元测试也是如此。大多数开发人员已经编写了某种类型的测试(自动化或非自动化)。这个想法是将花在测试上的时间重新定向到能够提供长期效益的事情上。
Don’t take this as a recommendation to make a career-limiting move. Developers do things without permission all the time: debugging code, reading email, writing code comments, creating flow diagrams, and so on. These are all tasks that developers do as a regular part of the job. The same goes for unit testing. Most developers already write tests of some sort (automated or not). The idea is to redirect the time spent on tests into something that will provide benefits in the long term.
自上而下的行动通常以两种方式之一开始。经理或开发人员将启动该流程,并开始组织的其他部分逐步朝这个方向前进。或者,中层经理可能会观看演示、阅读一本书(例如这本书)或与同事讨论对他们的工作方式进行特定改变的好处。这样的经理通常会通过向其他团队的人员进行演示来启动该流程,甚至利用他们的权力来实现变革。
The top-down move usually starts in one of two ways. A manager or a developer will initiate the process and start the rest of the organization moving in that direction, piece by piece. Or a mid-level manager may see a presentation, read a book (such as this one), or talk to a colleague about the benefits of specific changes to the way they work. Such a manager will usually initiate the process by giving a presentation to people in other teams or even using their authority to make the change happen.
这是在大型组织中开始进行单元测试的一种强大方法(它也适合其他类型的转型或新技能)。宣布一项将持续两到三个月的实验。它将仅适用于一个预先选择的团队,并且仅与实际应用程序中的一两个组件相关。确保它不会太冒险。如果失败,公司不会破产或失去主要客户。它也不应该是无用的:实验必须提供真正的价值,而不仅仅是作为游乐场。它必须是您最终将推入代码库并最终在生产中使用的东西;它不应该是一段写完就忘记的代码。
Here’s a powerful way to get started with unit testing in a large organization (it could also fit other types of transformation or new skills). Declare an experiment that will last two to three months. It will apply to only one pre-picked team and relate to only one or two components in a real application. Make sure it’s not too risky. If it fails, the company won’t go under or lose a major client. It also shouldn’t be useless: the experiment must provide real value and not just serve as a playground. It has to be something you’ll end up pushing into your codebase and use in production eventually; it shouldn’t be a write-and-forget piece of code.
“实验”这个词传达了这种改变是暂时的,如果不起作用,团队可以回到之前的状态。此外,这项工作是有时间限制的,因此我们知道实验何时完成。
The word “experiment” conveys that the change is temporary, and if it doesn’t work out, the team can go back to the way they were before. Also, the effort is time-boxed, so we know when the experiment is finished.
这种方法可以帮助人们对重大变化感到更加放心,因为它减少了组织的风险、受影响的人数(以及反对的人数)以及因担心“永远改变事情”而提出的反对意见的数量”。
Such an approach helps people feel more at ease with big changes, because it reduces the risk to the organization, the number of people affected (and thus the number of people objecting), and the number of objections relating to fear of changing things “forever.”
这里有另一个提示:当面对一个实验的多种选择时,或者如果你反对推动另一种工作方式,请问,“我们想先尝试哪个想法?”
Here’s another hint: when faced with multiple options for an experiment, or if you get objections pushing for another way of working, ask, “Which idea do we want to experiment with first?”
请做好准备,您的想法可能不会从所有实验选项中被选中。当紧要关头,无论你喜欢与否,你都必须根据领导层的共识决定进行实验。
Be prepared that your idea might not be selected from among all the options for an experiment. When push comes to shove, you have to hold experiments based on what the consensus of leadership decides, whether you like it or not.
参与其他人的实验的好处在于,就像你的实验一样,它们是有时间限制的且是暂时的!最好的结果可能是另一种方法可以解决您试图解决的问题,并且您可能希望继续别人的实验。然而,如果你讨厌这个实验,只要记住它是暂时的,你可以推动下一个实验。
The nice thing about going with other people’s experiments is that, like with yours, they are time-boxed and temporary! The best outcome might be that another approach fixes what you were trying to fix, and you might want to keep someone else’s experiment going. However, if you hate the experiment, just remember that it’s temporary, and you can push for the next experiment.
请务必在实验前后记录一组基准指标。这些指标应该与您尝试更改的内容相关,例如消除构建的等待时间、减少产品上市的准备时间或减少生产中发现的错误数量。
Be sure to record a baseline set of metrics before and after the experiment. These metrics should be related to things you’re trying to change, such as eliminating waiting times for a build, reducing the lead time for a product to go out the door, or reducing the number of bugs found in production.
要更深入地了解您可能使用的各种指标,请查看我的演讲“谎言、该死的谎言和指标”,您可以在我的博客中找到该演讲:https: //pipelinedriven.org/article/video-lies-damned -谎言和指标。
To dive deeper into the various metrics you might use, take a look at my talk “Lies, Damned Lies, and Metrics,” which you can find in my blog at https://pipelinedriven.org/article/video-lies-damned-lies-and-metrics.
我强烈建议聘请外部人员来帮助进行变革。外部顾问来帮助单元测试和相关事务比在公司工作的人有优势:
I highly recommend getting an outside person to help with the change. An outside consultant coming in to help with unit testing and related matters has advantages over someone who works in the company:
言论自由——顾问可以说出公司内部人员可能不愿意从公司工作人员那里听到的话(“代码完整性很差”,“你的测试不可读”等等)。
Freedom to speak—A consultant can say things that people inside the company may not be willing to hear from someone who works there (“The code integrity is bad,” “Your tests are unreadable,” and so on).
Experience—A consultant will have more experience dealing with resistance from the inside, coming up with good answers to tough questions, and knowing which buttons to push to get things going.
奉献时间——对于顾问来说,这是他们的工作。与公司中其他有比推动变革(例如编写软件)更重要的事情要做的员工不同,顾问全职从事这项工作并致力于此目的。
Dedicated time—For a consultant, this is their job. Unlike other employees in the company who have better things to do than push for change (like writing software), the consultant does this full time and is dedicated to this purpose.
我经常看到变革失败是因为过度劳累的冠军没有时间专注于这个过程。
I’ve often seen a change break down because an overworked champion doesn’t have the time to dedicate to the process.
保持变更的进度和状态可见非常重要。在走廊的墙壁上或人们聚集的与食品相关的区域悬挂白板或海报。显示的数据应该与您想要实现的目标相关。例如:
It’s important to keep the progress and status of the change visible. Hang whiteboards or posters on walls in corridors or in the food-related areas where people congregate. The data displayed should be related to the goals you’re trying to achieve. For example:
Show the number of passing or failing tests in the last nightly build.
Keep a chart showing which teams are already running an automated build process.
如果您设定了目标,请张贴迭代进度的 Scrum 燃尽图或测试代码覆盖率报告(如图 11.1 所示)。(您可以在www.controlchaos.com上了解有关 Scrum 的更多信息。)
Put up a Scrum burndown chart of iteration progress or a test-code-coverage report (as shown in figure 11.1) if that’s what you have your goals set to. (You can learn more about Scrum at www.controlchaos.com.)
图 11.1 TeamCity 中使用 NCover 的测试代码覆盖率报告示例
Figure 11.1 An example of a test-code-coverage report in TeamCity with NCover
Put up contact details for yourself and all the champions, so someone can answer any questions that arise.
设置一个大屏幕显示器,始终以大粗体图形显示构建的状态、当前正在运行的内容以及失败的内容。将其放在所有开发人员都可以看到的显眼位置 - 例如,在人流量大的走廊中,或者在团队房间主墙的顶部。
Set up a big-screen display that’s always showing, in big bold graphics, the status of the builds, what’s currently running, and what’s failing. Put that in a visible place where all developers can see—in a well-trafficked corridor, for example, or at the top of the team room’s main wall.
Your aim in using these charts is to connect with two groups:
经历变革的群体——随着图表(向所有人开放)更新,该群体中的人们将获得更大的成就感和自豪感,并且他们会感到更有动力完成该过程,因为它对其他人是可见的。他们还能够跟踪自己与其他群体相比的表现。他们可能会更加努力,因为他们知道另一个小组更快地实施了特定的做法。
The group undergoing the change—People in this group will gain a greater feeling of accomplishment and pride as the charts (which are open to everyone) are updated, and they’ll feel more compelled to complete the process because it’s visible to others. They’ll also be able to keep track of how they’re doing compared to other groups. They may push harder, knowing that another group implemented specific practices more quickly.
Those in the organization who aren’t part of the process—You’re raising interest and curiosity among these people, triggering conversations and buzz, and creating a current that they can join if they choose.
如果没有目标,变革将难以衡量并与他人沟通。这将是一个模糊的“东西”,一旦出现麻烦迹象就很容易被关闭。
Without goals, the change will be hard to measure and to communicate to others. It will be a vague “something” that can easily be shut down at the first sign of trouble.
在组织层面,单元测试通常是更大目标的一部分,通常与持续交付相关。如果您属于这种情况,我强烈建议您使用四个常见的 DevOps 指标:
At the organizational level, unit tests are generally part of a bigger set of goals, usually related to continuous delivery. If that’s the case for you, I highly recommend using the four common DevOps metrics:
Deployment frequency—How often an organization successfully releases to production.
变更前置时间——功能请求进入生产所需的时间。请注意,许多地方错误地将其发布为提交到生产所需的时间,从组织的角度来看,这只是功能经历的旅程的一部分。如果您从提交时间进行测量,则更接近于测量功能从提交到特定点的“周期时间”。提前期由多个周期时间组成。
Lead time for changes—The time it takes a feature request to get into production. Note that many places incorrectly publish this as the amount of time it takes a commit to get into production, which is only a part of the journey that a feature goes through, from an organizational standpoint. If you’re measuring from commit time, you’re closer to measuring the “cycle time” of a feature from commit up to a specific point. Lead time is made up of multiple cycle times.
逃逸错误/变更失败率——每个单元(通常是发布、部署或时间)在生产中发现的失败数量。您还可以使用导致生产失败的部署百分比。
Escaped bugs/change failure rate—The number of failures found in production per some unit, usually release, deployment, or time. You can also use the percentage of deployments causing a failure in production.
Time to restore service—How long it takes an organization to recover from a failure in production.
这四个指标就是我们所说的滞后指标,它们很难伪造(尽管它们在大多数地方很容易衡量)。它们非常有助于确保我们不会在实验结果上欺骗自己。
These four are what we’d call lagging indicators, and they’re very hard to fake (although they’re pretty easy to measure in most places). They are great in making sure we do not lie to ourselves about the results of experiments.
通常,我们希望获得更快的反馈,以确保我们走在正确的道路上。这就是领先指标的用武之地。领先指标是我们日常可以控制的东西——代码覆盖率、测试数量、构建运行时间等等。它们更容易伪造,但与滞后指标相结合,它们通常可以为我们提供早期迹象,表明我们可能正在走正确的道路。
Often we’d like faster feedback to ensure that we’re going the right way. That’s where leading indicators come in. Leading indicators are things we can control on a day-to-day basis—code coverage, number of tests, build run time, and more. They are easier to fake, but combined with lagging indicators, they can often provide us with early signs that we might be going the right way.
图 11.2 显示了您可以在组织中使用的滞后指标和领先指标的示例结构和想法。您可以在https://pipelinedriven.org/article/a-metrics-framework-for-continuous-delivery找到带有颜色的高分辨率图像。
Figure 11.2 shows a sample structure and ideas for lagging and leading indicators you can use in your organization. You can find a high-resolution image with color at https://pipelinedriven.org/article/a-metrics-framework-for-continuous-delivery.
Figure 11.2 An example of a metrics framework for use in continuous delivery
Indicator categories and groups
I usually break up leading indicators into two groups:
Engineering management level—Metrics that require cross-team collaboration or aggregate metrics across multiple teams
I also like to categorize them based on what they will be used to solve:
Progress—Used to solve visibility and decision making on the plan
Skills—Track that we are slowly removing knowledge barriers inside teams or across teams
这些指标大多是定量的(即,它们是可以测量的数字),但也有一些是定性的,即您询问人们对某事的感受或想法。我用的是
The metrics are mostly quantitative (i.e., they are numbers that can be measured), but a few are qualitative, in that you ask people how they feel or think about something. The ones I use are
How confident you are that the tests can and will find bugs in the code if they arise (from 1 to 5)? Take the average of the responses from the team members or across multiple teams.
您可以在每次回顾会议上提出这些调查,需要五分钟的时间来回答。
These are surveys you can ask at each retrospective meeting, and they take five minutes to answer.
对于所有领先和滞后指标,您希望看到趋势线,而不仅仅是数字快照。随着时间的推移,线条可以让你看到自己的状态是好转还是变差。
For all leading and lagging indicators, you want to see trend lines, not just snapshots of numbers. Lines over time is how you see if you’re getting better or worse.
不要陷入拥有一个带有大量数字的漂亮仪表板的陷阱。没有背景的数字没有好坏之分。趋势线告诉您本周您是否比上周更好。
Don’t fall into the trap of having a nice dashboard with large numbers on it. Numbers without context are not good or bad. Trend lines tell you if you’re better this week than you were last week.
总是有障碍。大多数将来自组织结构内部,有些将来自技术领域。技术问题更容易解决,因为这是找到正确解决方案的问题。组织方面的人员需要关怀和关注以及心理方法。
There are always hurdles. Most will come from within the organizational structure, and some will be technical. The technical ones are easier to fix, because it’s a matter of finding the right solution. The organizational ones need care and attention and a psychological approach.
当迭代出现问题、测试比预期慢等等时,不要屈服于暂时失败的感觉,这一点很重要。有时很难开始,您需要坚持至少几个月才能开始适应新流程并解决所有问题。让管理层承诺即使事情没有按计划进行,也会持续至少三个月。提前获得他们的同意很重要。您不想在充满压力的第一个月中四处奔走试图说服人们。
It’s important not to surrender to a feeling of temporary failure when an iteration goes bad, tests go slower than expected, and so on. It’s sometimes hard to get going, and you’ll need to persist for at least a couple of months to start feeling comfortable with the new process and to iron out all the kinks. Have management commit to continuing for at least three months even if things don’t go as planned. It’s important to get their agreement up front. You don’t want to be running around trying to convince people in the middle of a stressful first month.
另外,请吸收 Tim Ottinger 在 Twitter (@Tottinge) 上分享的这个简短的认识:“如果您的测试没有捕获所有缺陷,它们仍然可以更轻松地修复未捕获的缺陷。这是一个深刻的真理。”
Also, absorb this short realization, shared by Tim Ottinger on Twitter (@Tottinge): “If your tests don’t catch all defects, they still make it easier to fix the defects they didn’t catch. It is a profound truth.”
现在我们已经研究了确保事情顺利进行的方法,让我们看看一些可能导致失败的事情。
Now that we’ve looked at ways of ensuring things go right, let’s look at some things that can lead to failure.
在本书的前言中,我谈到了我参与的一个失败的项目,部分原因是单元测试没有正确实施。这是项目失败的一种方式。我将在这里讨论其他几个问题,以及导致我的项目成本高昂的一个问题,以及针对这些问题可以采取的一些措施。
In the preface to this book, I talked about one project I was involved with that failed, partly because unit testing wasn’t implemented correctly. That’s one way a project can fail. I’ll discuss several others here, along with one that cost me that project, and some things that can be done about them.
在我见过变革失败的地方,缺乏驱动力是最有力的因素。成为变革的持续驱动力是有代价的。你需要从正常工作中抽出时间来教导他人、帮助他们以及为变革而发动内部政治战争。你需要愿意为这些任务付出时间,否则改变不会发生。如第 11.2.4 节所述,引入外部人员将帮助您寻求一致的驱动力。
In the places where I’ve seen change fail, the lack of a driving force was the most powerful factor in play. Being a consistent driving force of change has its price. It will take time away from your normal job to teach others, help them, and wage internal political wars for change. You need to be willing to surrender time for these tasks, or the change won’t happen. Bringing in an outside person, as mentioned in section 11.2.4, will help you in your quest for a consistent driving force.
如果你的老板明确告诉你不要做出改变,除了试图说服管理层看到你所看到的之外,你无能为力。但有时缺乏支持的情况比这要微妙得多,关键是要意识到你面临着反对。
If your boss explicitly tells you not to make the change, there isn’t a whole lot you can do, besides trying to convince management to see what you see. But sometimes the lack of support is much more subtle than that, and the trick is to realize that you’re facing opposition.
例如,您可能会被告知:“当然,继续实施这些测试。我们将增加您 10% 的时间来做这件事。” 对于开始单元测试工作来说,任何低于 30% 的值都是不现实的。这是管理者试图阻止趋势的一种方式——阻止它的存在。
For example, you may be told, “Sure, go ahead and implement those tests. We’re adding 10% to your time to do this.” Anything below 30% isn’t realistic for beginning a unit testing effort. This is one way a manager may try to stop a trend—by choking it out of existence.
你需要认识到你正面临着反对,但一旦你知道要寻找什么,就很容易识别。当你告诉他们他们的局限性不现实时,你会被告知,“所以不要这样做。”
You need to recognize that you’re facing opposition, but once you know what to look for, it’s easy to identify. When you tell them that their limitations aren’t realistic, you’ll be told, “So don’t do it.”
如果您计划在事先不知道如何编写良好的单元测试的情况下实施单元测试,请帮自己一个大忙:让有经验并遵循良好实践的人参与(例如本书中概述的那些)。
If you’re planning to implement unit testing without prior knowledge of how to write good unit tests, do yourself one big favor: involve someone who has experience and follow good practices (such as those outlined in this book).
我见过开发人员在没有正确了解该做什么或从哪里开始的情况下就跳入深水,这不是一个好地方。不仅需要花费大量的时间来学习如何做出适合您的情况的更改,而且您还会因为一开始的实施不佳而失去很多可信度。这可能会导致试点项目被关闭。
I’ve seen developers jump into the deep water without a proper understanding of what to do or where to start, and that’s not a good place to be. Not only will it take a huge amount of time to learn how to make changes that are acceptable for your situation, but you’ll also lose a lot of credibility along the way for starting out with a bad implementation. This can lead to the pilot project being shut down.
如果你读过这本书的序言,你就会知道这发生在我身上。你只有几个月的时间来加快进度并让上级相信你正在通过实验取得成果。充分利用这段时间,并尽可能消除任何风险。如果您不知道如何编写好的测试,请阅读书籍或咨询顾问。如果您不知道如何使代码可测试,请执行相同的操作。不要浪费时间重新发明测试方法。
If you read this book’s preface, you’ll know that this happened to me. You have only a couple of months to get things up to speed and convince the higher-ups that you’re achieving results with experiments. Make that time count, and remove any risks that you can. If you don’t know how to write good tests, read a book or get a consultant. If you don’t know how to make your code testable, do the same. Don’t waste time reinventing testing methods.
如果您的团队不支持您的努力,那么几乎不可能成功,因为您将很难将新流程上的额外工作与常规工作结合起来。您应该努力让您的团队成为新流程的一部分,或者至少不干扰它。
If your team doesn’t support your efforts, it will be nearly impossible to succeed, because you’ll have a hard time consolidating your extra work on the new process with your regular work. You should strive to have your team be part of the new process or at least not interfere with it.
与您的团队成员讨论这些变化。有时,获得他们的一一支持是一个很好的开始,但与他们作为一个团队讨论你的努力并回答他们的难题也很有价值。无论您做什么,都不要将团队的支持视为理所当然。确保你知道自己要做什么;这些是您每天必须与之合作的人。
Talk to your team members about the changes. Getting their support one by one is sometimes a good way to start, but talking to them as a group about your efforts—and answering their hard questions—can also prove valuable. Whatever you do, don’t take the team’s support for granted. Make sure you know what you’re getting into; these are the people you have to work with on a daily basis.
我在《弹性领导力》(Manning,2016 年)一书中用一个完整的章节撰写并介绍了影响行为。如果您觉得这个主题很有趣,我建议您选择该主题,或者在5whys.com上阅读更多相关内容。
I’ve written and covered influencing behaviors as a full chapter in my book Elastic Leadership (Manning, 2016). If you find this topic interesting, I recommend picking that one up, or reading more about it at 5whys.com.
我发现比单元测试更令人着迷的事情之一是人以及他们的行为方式的原因。尝试让某人开始做某事(例如 TDD)可能会非常令人沮丧,并且无论您尽最大努力,他们就是不会这样做。你可能已经尝试过与他们推理,但你发现他们对你的闲聊没有任何反应。
One of the things I find even more fascinating than unit tests is people and why they behave the way they do. It can be very frustrating to try to get someone to start doing something (like TDD, for example), and regardless of your best efforts, they just won’t do it. You may have already tried reasoning with them, but you see they don’t do anything in response to your little talk.
在 Kerry Patterson、Joseph Grenny、David Maxfield、Ron McMillan 和 Al Switzler 所著的《影响者:改变一切的力量》 (McGraw-Hill,2007 年)一书中,您会发现以下口头禅(释义):
In the book Influencer: The Power to Change Anything (McGraw-Hill, 2007) by Kerry Patterson, Joseph Grenny, David Maxfield, Ron McMillan, and Al Switzler, you’ll find the following mantra (paraphrased):
对于你看到的每一种行为,世界都是为该行为的发生而完美设计的。这意味着除了人想要做某事或能够做某事之外,还有其他因素影响他们的行为。然而我们很少会超越这两个因素。
For every behavior that you see, the world is perfectly designed for that behavior to happen. That means that there are other factors besides the person wanting to do something or being able to do it that influence their behavior. Yet we rarely look beyond those two factors.
The book exposes us to six influence factors:
Personal ability—Does the person have all the skills or knowledge to perform what is required?
Personal motivation—Does the person take satisfaction from the right behavior or dislike the wrong behavior? Do they have the self-control to engage in the behavior when it’s hardest to do so?
Social ability—Do you or others provide the help, information, and resources required by that person, particularly at critical times?
Social motivation—Are the people around them actively encouraging the right behavior and discouraging the wrong behavior? Are you or others modeling the right behavior in an effective way?
Structural (environmental) ability—Are there aspects in the environment (building, budget, and so on) that make the behavior convenient, easy, and safe? Are there enough cues and reminders to stay on course?
结构性动机——当你或其他人的行为正确或错误时,是否有明确且有意义的奖励(例如工资、奖金或激励)?短期奖励是否与您想要强化或想要避免的长期结果和行为相匹配?
Structural motivation—Are there clear and meaningful rewards (such as pay, bonuses, or incentives) when you or others behave the right or wrong way? Do short-term rewards match the desired long-term results and behaviors you want to reinforce or want to avoid?
请将此视为一个简短的清单,用于开始了解为什么事情不按您的方式进行。然后考虑另一个重要事实:游戏中可能有多个因素。为了改变行为,你应该改变所有起作用的因素。如果您只更改其中一项,则行为不会改变。
Consider this a short checklist for starting to understand why things aren’t going your way. Then consider another important fact: there might be more than one factor in play. For the behavior to change, you should change all the factors in play. If you change just one, the behavior won’t change.
表 11.1 是关于不执行 TDD 的人的虚构清单的示例。(请记住,每个组织中的每个人都会有所不同。)
Table 11.1 is an example of an imaginary checklist about someone not performing TDD. (Keep in mind that this will differ for each person in each organization.)
Table 11.1 Influence factors checklist
我在右栏中需要工作的项目旁边添加了星号。在这里我确定了两个需要解决的问题。仅解决构建机器预算问题不会改变行为。他们必须拥有一台构建机器,并阻止他们的经理因为快速运送蹩脚的东西而给予奖金。
I put asterisks next to the items in the right column that require work. Here I’ve identified two issues that need to be resolved. Solving only the build machine budget problem won’t change the behavior. They have to get a build machine and deter their managers from giving a bonus on shipping crappy stuff quickly.
我在《Notes to a Software Team Leader》(Team Agile Publishing,2014 年)中写了更多相关内容,这是一本关于运营技术团队的书。您可以在5whys.com上找到它。
I write much more on this in Notes to a Software Team Leader (Team Agile Publishing, 2014), a book about running a technical team. You can find it at 5whys.com.
本节涵盖了我在不同地方遇到的一些问题。它们通常源于这样的前提:实施单元测试可能会伤害某人个人——担心他们的最后期限的经理或担心他们的相关性的 QA 员工。一旦您了解了问题的来源,直接或间接解决该问题就很重要。否则,总会有微妙的阻力。
This section covers some questions I’ve come across in various places. They usually arise from the premise that implementing unit testing can hurt someone personally—a manager concerned about their deadlines or a QA employee concerned about their relevance. Once you understand where a question is coming from, it’s important to address the issue, directly or indirectly. Otherwise, there will always be subtle resistance.
团队领导、项目经理和客户通常会问单元测试会给流程增加多少时间。他们是时间上最前线的人。
Team leaders, project managers, and clients are the ones who usually ask how much time unit testing will add to the process. They’re the people at the front lines in terms of timing.
让我们从一些事实开始。研究表明,提高项目的整体代码质量可以提高生产力并缩短工期。这与编写测试会使编码变慢的事实相匹配吗?主要是通过可维护性和修复错误的容易性。
Let’s begin with some facts. Studies have shown that raising the overall code quality in a project can increase productivity and shorten schedules. How does this match up with the fact that writing tests makes coding slower? Through maintainability and the ease of fixing bugs, mostly.
注意有关代码质量和生产力的研究,请参阅《编程生产力》 ( McGraw-Hill College,1986 年)和《软件评估、基准测试和最佳实践》 ( Addison-Wesley Professional,2000 年),均由 Capers Jones 编写。
Note For studies on code quality and productivity, see Programming Productivity (McGraw-Hill College, 1986) and Software Assessments, Benchmarks, and Best Practices (Addison-Wesley Professional, 2000), both by Capers Jones.
当询问时间时,团队领导可能真的会问:“当我们超出了截止日期时,我应该告诉我的项目经理什么?” 他们实际上可能认为这个过程很有用,但正在为即将到来的战斗寻找弹药。他们提出的问题也可能不是针对整个产品,而是针对特定的特性集或功能。另一方面,询问时间安排的项目经理或客户通常会谈论完整的产品发布。
When asking about time, team leaders may really be asking, “What should I tell my project manager when we go way past our due date?” They may actually think the process is useful but be looking for ammunition for the upcoming battle. They may also be asking the question not in terms of the whole product but in terms of specific feature sets or functionality. A project manager or customer who asks about timing, on the other hand, will usually be talking in terms of full product releases.
因为不同的人关心的范围不同,所以你的答案可能会有所不同。例如,单元测试可以使实现特定功能所需的时间加倍,但产品的总体发布日期实际上可能会缩短。为了理解这一点,让我们看一个我参与过的真实例子。
Because different people care about different scopes, your answers may vary. For example, unit testing can double the time it takes to implement a specific feature, but the overall release date for the product may actually be reduced. To understand this, let’s look at a real example I was involved with.
我咨询过的一家大公司希望在他们的流程中实施单元测试,从试点项目开始。该试点由一组开发人员组成,向大型现有应用程序添加新功能。该公司的主要生计是创建这个大型计费应用程序并为不同的客户定制其中的部分内容。该公司在全球拥有数千名开发人员。
A large company I consulted with wanted to implement unit testing in their process, beginning with a pilot project. The pilot consisted of a group of developers adding a new feature to a large existing application. The company’s main livelihood was in creating this large billing application and customizing parts of it for various clients. The company had thousands of developers around the world.
The following measures were taken to test the pilot’s success:
对于不同团队为不同客户创建的类似功能收集了相同的统计数据。这两个功能的大小几乎相同,团队的技能和经验水平也大致相同。这两项任务都是定制工作——一项带有单元测试,另一项则没有。表 11.2 显示了时间上的差异。
The same statistics were collected for a similar feature created by a different team for a different client. The two features were nearly the same size, and the teams were roughly at the same skill and experience level. Both tasks were customization efforts—one with unit tests, the other without. Table 11.2 shows the differences in time.
Table 11.2 Team progress and output measured with and without tests
总体而言,经过测试的发布所花费的时间比未经测试的要少。尽管如此,进行单元测试的团队的经理最初并不相信试点会成功,因为他们只将实施(编码)统计数据(表 11.2 中的第一行)视为成功的标准,而不是底线。编写该功能花费了两倍的时间(因为单元测试要求您编写更多代码)。尽管如此,当 QA 团队发现需要处理的错误较少时,额外的时间还是得到了补偿。
Overall, the time it took to release with tests was less than without tests. Still, the managers on the team with unit tests didn’t initially believe the pilot would be a success, because they only looked at the implementation (coding) statistic (the first row in table 11.2) as the criteria for success, instead of the bottom line. It took twice the amount of time to code the feature (because unit tests require you to write more code). Despite this, the extra time was more than compensated for when the QA team found fewer bugs to deal with.
这就是为什么需要强调的是,尽管单元测试会增加实现功能所需的时间,但由于质量和可维护性的提高,总体时间要求在产品的发布周期中得到了平衡。
That’s why it’s important to emphasize that although unit testing can increase the amount of time it takes to implement a feature, the overall time requirements balance out over the product’s release cycle because of increased quality and maintainability.
单元测试并不会消除与 QA 相关的工作。QA 工程师将收到带有完整单元测试套件的应用程序,这意味着他们可以在开始自己的测试过程之前确保所有单元测试都通过。进行单元测试实际上会让他们的工作变得更有趣。他们将能够专注于在现实场景中查找更多逻辑(适用)错误,而不是进行 UI 调试(每单击一次按钮就会导致某种异常)。单元测试提供了针对错误的第一层防御,QA 工作提供了第二层——用户接受层。与安全性一样,应用程序始终需要具有不止一层的保护。让 QA 流程专注于更大的问题可以产生更好的应用程序。
Unit testing doesn’t eliminate QA-related jobs. QA engineers will receive the application with full unit test suites, which means they can make sure all the unit tests pass before they start their own testing process. Having unit tests in place will actually make their job more interesting. Instead of doing UI debugging (where every second button click results in an exception of some sort), they’ll be able to focus on finding more logical (applicative) bugs in real-world scenarios. Unit tests provide the first layer of defense against bugs, and QA work provides the second layer—the user acceptance layer. As with security, the application always needs to have more than one layer of protection. Allowing the QA process to focus on the larger issues can produce better applications.
在某些地方,QA 工程师编写代码,他们可以帮助为应用程序编写单元测试。这与应用程序开发人员的工作一起发生,而不是代替它。开发人员和 QA 工程师都可以编写单元测试。
In some places, QA engineers write code, and they can help write unit tests for the application. That happens in conjunction with the work of the application developers and not instead of it. Both developers and QA engineers can write unit tests.
我可以指出,没有任何关于单元测试是否有助于实现更好的代码质量的具体研究。大多数相关研究都讨论采用特定的敏捷方法,单元测试只是其中之一。一些经验证据可以从网络上收集到,公司和同事取得了很好的成果,并且在没有测试的情况下永远不想回到代码库。有关 TDD 的一些研究可以在 QA Lead 中找到: http: //mng.bz/dddo。
There aren’t any specific studies on whether unit testing helps achieve better code quality that I can point to. Most related studies talk about adopting specific agile methods, with unit testing being just one of them. Some empirical evidence can be gleaned from the web, of companies and colleagues having great results and never wanting to go back to a codebase without tests. A few studies on TDD can be found at The QA Lead here: http://mng.bz/dddo.
您可能不再有 QA 部门,但这仍然是一种非常普遍的做法。不管怎样,你仍然会发现错误。请使用多个级别的测试(如第 10 章中所述),以获得跨应用程序多个层的信心。单元测试为您提供快速反馈和易于维护,但它们会留下一些信心,而这只能通过某些级别的集成测试来获得。
You may not have a QA department anymore, but this is still a very prevalent practice. Either way, you’ll still be finding bugs. Please use tests at multiple levels, as described in chapter 10, to gain confidence across many layers of your application. Unit tests give you fast feedback and easy maintainability, but they leave some confidence behind, which can only be gained through some levels of integration tests.
20 世纪 70 年代和 80 年代进行的研究表明,通常 80% 的错误出现在 20% 的代码中。诀窍是找到问题最多的代码。通常,任何团队都可以告诉您哪些组件问题最严重。从那里开始。您始终可以添加一些与每个类的错误数量相关的指标。
Studies conducted in the 1970s and 1980s showed that, typically, 80% of bugs are found in 20% of the code. The trick is to find the code that has the most problems. More often than not, any team can tell you which components are the most problematic. Start there. You can always add some metrics related to the number of bugs per class.
测试遗留代码需要采用与通过测试编写新代码不同的方法。详细信息请参见第 12 章。
Testing legacy code requires a different approach than when writing new code with tests. See chapter 12 for more details.
你即使您开发软件和硬件的组合,也可以使用单元测试。查看上一章中提到的测试层,确保涵盖软件和硬件。硬件测试通常需要使用不同级别的模拟器和仿真器,但通常的做法是对低级嵌入式和高级代码进行一套测试。
You can use unit tests even if you develop a combination of software and hardware. Look into the test layers mentioned in the previous chapter to make sure you cover both software and hardware. Hardware testing usually requires the use of simulators and emulators at various levels, but it is a common practice to have a suite of tests both for low-level embedded and high-level code.
您需要确保您的测试在应该失败的时候失败,在应该通过的时候通过。TDD 是确保您不会忘记检查这些事情的好方法。请参阅第 1 章,了解 TDD 的简要介绍。
You need to make sure your tests fail when they should and pass when they should. TDD is a great way to make sure you don’t forget to check those things. See chapter 1 for a short walk-through of TDD.
调试器对于多线程代码没有多大帮助。另外,您可能确定您的代码工作正常,但是其他人的代码呢?你怎么知道它有效?他们如何知道您的代码可以工作并且在进行更改时没有破坏任何内容?请记住,编码是代码生命周期的第一步。代码在其生命周期的大部分时间都处于维护模式。您需要使用单元测试确保它会在发生故障时告诉人们。
Debuggers don’t help much with multithreaded code. Also, you may be sure your code works fine, but what about other people’s code? How do you know it works? How do they know your code works and that they haven’t broken anything when they make changes? Remember that coding is the first step in the life of the code. Most of its life, the code will be in maintenance mode. You need to make sure it will tell people when it breaks, using unit tests.
Curtis、Krasner 和 Iscoe 进行的一项研究(“大型系统软件设计过程的现场研究”,Communications of the ACM 31,第 11 期(1988 年 11 月),1268-87)表明,大多数缺陷并不来自代码本身,但由于人们之间的沟通不畅、需求不断变化以及缺乏应用程序领域知识而导致。即使您是世界上最伟大的程序员,如果有人告诉您编写错误的代码,您也很可能会这样做。当您需要更改它时,您会很高兴对其他所有内容进行了测试,以确保不会破坏它。
A study held by Curtis, Krasner, and Iscoe (“A field study of the software design process for large systems,” Communications of the ACM 31, no. 11 (November 1988), 1268-87) showed that most defects don’t come from the code itself but result from miscommunication between people, requirements that keep changing, and a lack of application domain knowledge. Even if you’re the world’s greatest coder, chances are that if someone tells you to code the wrong thing, you’ll do it. When you need to change it, you’ll be glad you have tests for everything else, to make sure you don’t break it.
TDD 是一种风格选择。我个人认为 TDD 有很大的价值,许多人发现它富有成效且有益,但其他人发现在代码之后编写测试对他们来说已经足够好了。您可以自行选择。
TDD is a style choice. I personally see a lot of value in TDD, and many people find it productive and beneficial, but others find that writing tests after the code is good enough for them. You can make your own choice.
Implementing unit testing in their organization is something that many readers of this book will have to face at one time or another.
Make sure that you don’t alienate the people who can help you. Recognize champions and blockers inside the organization. Make both groups part of the change process.
Identify possible starting points. Start with a small team or project with a limited scope to get a quick win and minimize project duration risks.
Make the progress visible to everyone. Aim for specific goals, metrics, and KPIs.
Take note of potential causes of failure, such as the lack of a driving force and lack of political or team support.
Be prepared to have good answers to the questions you’re likely to be asked.
我曾经为一家生产计费软件的大型开发商店提供咨询。他们拥有超过 10,000 名开发人员,并在产品、子产品和相互交织的项目中混合使用 .NET、Java 和 C++。该软件已经以某种形式存在了五年多,大多数开发人员的任务是维护和构建现有功能。
I once consulted for a large development shop that produced billing software. They had over 10,000 developers and mixed .NET, Java, and C++ in products, subproducts, and intertwined projects. The software had existed in one form or another for over five years, and most of the developers were tasked with maintaining and building on top of existing functionality.
我的工作是帮助多个部门(使用所有语言)学习 TDD 技术。对于与我合作的大约 90% 的开发人员来说,由于多种原因,这从未成为现实,其中一些是遗留代码的结果:
My job was to help several divisions (using all languages) learn TDD techniques. For about 90% of the developers I worked with, this never became a reality for several reasons, some of which were a result of legacy code:
任何曾经尝试向现有系统添加测试的人都知道,大多数此类系统几乎不可能为其编写测试。它们通常在软件中没有适当的位置(称为接缝)来编写,以允许扩展或替换现有组件。
Anyone who’s ever tried to add tests to an existing system knows that most such systems are almost impossible to write tests for. They were usually written without proper places (called seams) in the software to allow extensions or replacements to existing components.
There are two problems that need to be addressed when dealing with legacy code:
There’s so much work, where should you start to add tests? Where should you focus your efforts?
How can you safely refactor your code if it has no tests to begin with?
本章将通过列出有帮助的技术、参考资料和工具来解决与处理遗留代码库相关的棘手问题。
This chapter will tackle these tough questions associated with approaching legacy codebases by listing techniques, references, and tools that can help.
假设您的组件内已有代码,您需要创建一个组件的优先级列表,对这些组件进行测试最有意义。有几个因素需要考虑,这些因素可能会影响每个组件的优先级:
Assuming you have existing code inside components, you’ll need to create a priority list of components for which testing makes the most sense. There are several factors to consider that can affect each component’s priority:
逻辑复杂性——这是指组件中的逻辑量,例如嵌套if、switch case 或递归。这种复杂度也称为圈复杂度,您可以使用各种工具自动检查它。
Logical complexity—This refers to the amount of logic in the component, such as nested ifs, switch cases, or recursion. Such complexity is also called cyclomatic complexity, and you can use various tools to check it automatically.
依赖级别——这是指组件中依赖的数量。为了测试这个类,你必须打破多少依赖关系?它是否与外部电子邮件组件通信,或者是否在某处调用静态日志方法?
Dependency level—This refers to the number of dependencies in the component. How many dependencies do you have to break in order to bring this class under test? Does it communicate with an outside email component, perhaps, or does it call a static log method somewhere?
Priority—This is the component’s general priority in the project.
您可以为每个组件对这些因素进行评级,从 1(低优先级)到 10(高优先级)。表 12.1 显示了这些因素的评级类别。我称之为测试可行性表。
You can give each component a rating for these factors, from 1 (low priority) to 10 (high priority). Table 12.1 shows classes with ratings for these factors. I call this a test-feasibility table.
Table 12.1 A simple test-feasibility table
根据表 12.1 中的数据,您可以创建一个如图 12.1 所示的图表,该图表按照项目的价值量和依赖项数量来绘制您的组件。您可以安全地忽略低于指定逻辑阈值(我通常设置为 2 或 3)的项目,因此Person和ConfigManager可以被忽略。您只剩下图 12.1 中最上面的两个组件。
From the data in table 12.1, you can create a diagram like the one shown in figure 12.1, which graphs your components by the amount of value to the project and number of dependencies. You can safely ignore items that are below your designated threshold of logic (which I usually set at 2 or 3), so Person and ConfigManager can be ignored. You’re left with only the top two components in figure 12.1.
Figure 12.1 Mapping components for test feasibility
有两种基本方法可以查看图表并决定首先要测试什么(见图 12.2):
There are two basic ways to look at the graph and decide what you’d like to test first (see figure 12.2):
Choose the one that’s more complex and easier to test (top left).
Choose the one that’s more complex and harder to test (top right).
Figure 12.2 Easy, hard, and irrelevant component mapping based on logic and dependencies
现在的问题是你应该走哪条路。你应该从简单的事情开始还是从困难的事情开始?
The question now is what path you should take. Should you start with the easy stuff or the hard stuff?
正如上一节所解释的,您可以从易于测试的组件开始,也可以从难以测试的组件开始(因为它们有很多依赖项)。每种策略都会带来不同的挑战。
As the previous section explained, you can start with the components that are easy to test or the ones that are hard to test (because they have many dependencies). Each strategy presents different challenges.
从依赖项较少的组件开始将使最初编写测试变得更快更容易。但有一个问题,如图 12.3 所示。
Starting out with the components that have fewer dependencies will make writing the tests initially much quicker and easier. But there’s a catch, as figure 12.3 demonstrates.
图 12.3 当从简单的组件开始时,测试组件所需的时间会越来越多,直到完成最难的组件。
Figure 12.3 When starting with the easy components, the time required to test components increases more and more until the hardest components are done.
图 12.3 显示了在项目生命周期内对组件进行测试需要多长时间。最初编写测试很容易,但随着时间的推移,您留下的组件越来越难测试,特别困难的组件在项目周期结束时等待着您,就在每个人都感到压力的时候将产品推出市场。
Figure 12.3 shows how long it takes to bring components under test during the lifetime of the project. Initially it’s easy to write tests, but as time goes by, you’re left with components that are increasingly harder and harder to test, with the particularly tough ones waiting for you at the end of the project cycle, just when everyone is stressed about pushing a product out the door.
如果您的团队对单元测试技术相对较新,那么值得从简单的组件开始。随着时间的推移,团队将学习处理更复杂的组件和依赖项所需的技术。对于这样的团队,明智的做法是首先避免所有组件超过特定数量的依赖项(合理的限制是四个)。
If your team is relatively new to unit testing techniques, it’s worth starting with the easy components. As time goes by, the team will learn the techniques needed to deal with the more complex components and dependencies. For such a team, it may be wise to initially avoid all components over a specific number of dependencies (with four being a reasonable limit).
从更困难的组件开始似乎是一个失败的提议,但只要您的团队拥有单元测试技术的经验,它就有一个好处。图 12.4 显示了在项目的生命周期内为单个组件编写测试的平均时间(如果您首先开始测试具有最多依赖项的组件)。
Starting with the more difficult components may seem like a losing proposition initially, but it has an upside as long as your team has experience with unit testing techniques. Figure 12.4 shows the average time to write a test for a single component over the lifetime of the project, if you start testing the components with the most dependencies first.
图 12.4 当您使用“硬优先”策略时,测试组件所需的时间最初很长,但随着更多依赖项被重构而减少。
Figure 12.4 When you use a hard-first strategy, the time required to test components is initially high, but then decreases as more dependencies are refactored away.
通过这种策略,您可能需要花费一天或更长时间才能对更复杂的组件进行最简单的测试。但请注意,相对于图 12.3 中的缓慢增长,编写测试所需的时间快速下降。每次您测试一个组件并重构它以使其更具可测试性时,您还可能正在解决它使用的依赖项或其他组件的可测试性问题。由于该组件有很多依赖项,因此重构它可以改进系统其他部分的功能。这就是快速下降的原因。
With this strategy, you could be spending a day or more to get even the simplest tests going on the more complex components. But notice the quick decline in the time required to write the tests relative to the slow incline in figure 12.3. Every time you bring a component under test and refactor it to make it more testable, you may also be solving testability issues for the dependencies it uses or for other components. Because that component has lots of dependencies, refactoring it can improve things for other parts of the system. That’s the reason for the quick decline.
只有当您的团队具有单元测试技术经验时,“困难优先”策略才可能实现,因为它更难实施。如果您的团队确实有经验,请使用组件的优先级来选择是从困难组件还是从简单组件开始。您可能想要选择一种组合,但重要的是您必须提前知道需要付出多少努力以及可能的后果是什么。
The hard-first strategy is only possible if your team has experience in unit testing techniques, because it’s harder to implement. If your team does have experience, use the priority aspect of components to choose whether to start with the hard or easy components. You might want to choose a mix, but it’s important that you know in advance how much effort will be involved and what the possible consequences are.
如果您确实计划重构代码以实现可测试性(以便可以编写单元测试),那么确保在重构阶段不会破坏任何内容的实用方法是针对生产系统编写集成式测试。
If you do plan to refactor your code for testability (so you can write unit tests), a practical way to make sure you don’t break anything during the refactoring phase is to write integration-style tests against your production system.
我为一个大型遗留项目提供咨询,与一位需要使用 XML 配置管理器的开发人员合作。该项目没有测试并且很难测试。这也是一个 C++ 项目,因此我们无法使用工具在不重构代码的情况下轻松地将组件与依赖项隔离。
I consulted on a large legacy project, working with a developer who needed to work on an XML configuration manager. The project had no tests and was hardly testable. It was also a C++ project, so we couldn’t use a tool to easily isolate components from dependencies without refactoring the code.
开发人员需要在 XML 文件中添加另一个值属性,并能够通过现有的配置组件读取和更改它。我们最终编写了几个集成测试,这些测试使用真实系统来保存和加载配置数据,并对配置组件正在检索和写入文件的值进行断言。这些测试将配置管理器的“原始”工作行为设置为我们的工作基础。
The developer needed to add another value attribute into the XML file and be able to read and change it through the existing configuration component. We ended up writing a couple of integration tests that used the real system to save and load configuration data and that asserted on the values the configuration component was retrieving and writing to the file. Those tests set the “original” working behavior of the configuration manager as our base of work.
接下来,我们编写了一个集成测试,该测试表明,一旦组件读取文件,它在内存中不包含具有我们尝试添加的名称的属性。我们证明了该功能缺失,现在我们有了一个测试,一旦我们将新属性添加到 XML 文件并从组件正确写入该文件,该测试就会通过。
Next, we wrote an integration test that showed that once the component was reading the file, it contained no attribute in memory with the name we were trying to add. We proved that the feature was missing, and we now had a test that would pass once we added the new attribute to the XML file and correctly wrote to it from the component.
一旦我们编写了保存和加载额外属性的代码,我们就运行了三个集成测试(两个测试针对原始基本实现,另一个测试尝试读取新属性)。这三个都通过了,所以我们知道在添加新功能时我们没有破坏现有功能。
Once we wrote the code that saved and loaded the extra attribute, we ran the three integration tests (two tests for the original base implementation and a new one that tried to read the new attribute). All three passed, so we knew that we hadn’t broken existing functionality while adding the new functionality.
As you can see, the process is relatively simple:
Add one or more integration tests (no mocks or stubs) to the system to prove the original system works as needed.
Refactor or add a failing test for the feature you’re trying to add to the system.
Refactor and change the system in small chunks, and run the integration tests as often as you can, to see if you break something.
有时,集成测试可能看起来比单元测试更容易编写,因为您不需要了解代码的内部结构或在哪里注入各种依赖项。但是,在本地系统上运行这些测试可能会很烦人或很耗时,因为您必须确保系统所需的每一件小事都已就位。
Sometimes, integration tests may seem easier to write than unit tests, because you don’t need to understand the internal structure of the code or where to inject various dependencies. But making those tests run on your local system may prove annoying or time consuming because you have to make sure every little thing the system needs is in place.
诀窍是处理系统中需要修复或添加功能的部分。不要把注意力集中在其他部分。这样,系统就会在正确的地方发展,而当你到达其他桥梁时,就需要跨越其他桥梁。
The trick is to work on the parts of the system that you need to fix or add features to. Don’t focus on the other parts. That way, the system grows in the right places, leaving other bridges to be crossed when you get to them.
随着您继续添加越来越多的测试,您可以重构系统并向其添加更多单元测试,将其发展为更易于维护和测试的系统。这需要时间(有时需要数月),但这是值得的。
As you continue adding more and more tests, you can refactor the system and add more unit tests to it, growing it into a more maintainable and testable system. This takes time (sometimes months and months), but it’s worth it.
Vladimir Khorikov 的《单元测试原理、实践和模式》 (Manning,2020 年)第 7 章包含此类重构的深入示例。请参阅该书了解更多详细信息。
Chapter 7 of Unit Testing Principles, Practices, and Patterns by Vladimir Khorikov (Manning, 2020) contains an in-depth example of such refactoring. Refer to that book for more details.
Michael Feathers 的《有效处理遗留代码》(Pearson,2004 年)是另一个有价值的资源,它处理了您在处理遗留代码时遇到的问题。它深入展示了本书不试图涵盖的许多重构技术和陷阱。它的价值相当于黄金的重量。得到它。
Working Effectively with Legacy Code by Michael Feathers (Pearson, 2004) is another valuable source that deals with the issues you’ll encounter with legacy code. It shows many refactoring techniques and gotchas in depth that this book doesn’t attempt to cover. It’s worth its weight in gold. Get it.
另一个名为 CodeScene 的工具可以让您发现遗留代码中的大量技术债务和隐藏问题等。它是一个商业工具,虽然我没有亲自使用过它,但我听说过一些很棒的东西。您可以在https://codescene.com/了解更多信息。
Another tool called CodeScene allows you to discover lots of technical debt and hidden issues in legacy code, among many other things. It is a commercial tool, and while I have not personally used it, I've heard great things. You can learn more about it at https://codescene.com/.
在开始为遗留代码编写测试之前,重要的是根据各个组件的依赖项数量、逻辑量以及每个组件在项目中的一般优先级来规划它们。组件的逻辑复杂度(或圈复杂度)是指组件中的逻辑量,例如嵌套if、switch case 或递归。
Before starting to write tests for legacy code, it’s important to map out the various components according to their number of dependencies, their amount of logic, and each component’s general priority in the project. A component’s logical complexity (or cyclomatic complexity) refers to the amount of logic in the component, such as nested ifs, switch cases, or recursion.
Once you have that information, you can choose the components to work on based on how easy or how hard it will be to get them under test.
如果您的团队在单元测试方面经验很少或根本没有,那么最好从简单的组件开始,并随着向系统添加越来越多的测试而增强团队的信心。
If your team has little or no experience in unit testing, it’s a good idea to start with the easy components and let the team’s confidence grow as they add more and more tests to the system.
If your team is experienced, getting the hard components under test first can help you get through the rest of the system more quickly.
Before a large-scale refactoring, write integration tests that will sustain that refactoring mostly unchanged. After the refactoring is completed, replace most of these integration tests with smaller and more maintainable unit tests.
在第 3 章中,我介绍了各种我称之为“已接受”的桩技术,因为它们通常被认为对于代码的可维护性和可读性以及它们指导我们编写的测试来说是安全的。在本附录中,我将描述一些不太被接受且不太安全的方法,我们可以通过这些方法在测试中伪造整个模块。
In chapter 3, I introduced various stubbing techniques that I called “accepted,” in that they are usually considered safe for both the maintainability and readability of the code and the tests that they guide us to write. In this appendix, I’ll describe a few of the less accepted and less safe ways in which we can fake whole modules in our tests.
我有关于全局修补和删除函数和模块的好消息和坏消息。是的,您可以做到——我将向您展示几种实现这一目标的方法。这是个好主意吗?我不相信。根据我的经验,使用我将向您展示的技术维护测试的成本往往比维护参数化良好或内置适当接缝的代码更糟糕。
I have good news and bad news about global patching and stubbing out functions and modules. Yes, you can do it—I’ll show you several ways to accomplish this. Is it a great idea? I’m not convinced. The costs of maintaining your tests with the techniques I’ll show you tend to be, from my experience, worse than maintaining code that is well parameterized or has proper seams built in.
然而,在特殊时期您可能需要使用这些技术。这种情况包括但不限于在您不拥有且无法更改的代码中伪造依赖项,有时在使用立即可执行的函数或模块时。另一种情况是,模块仅公开函数而不公开对象,这在很大程度上限制了伪造选项。
However, there might be special times when you need to use these techniques. Such times include, but are not limited to, faking dependencies in code that you do not own and cannot change, and sometimes when using immediately executable functions or modules. Another case is when a module exposes only functions without objects, which limits the faking options quite a bit.
尽量避免使用我在本附录中描述的技术。如果您可以找到一种方法来编写测试或重构代码,这样您就不需要这些方法,请使用这种方法。如果所有其他方法都失败了,那么本附录中的技术就是不可避免的邪恶。如果您必须使用它们,请尽量减少使用它们的次数。您的测试将会受到影响,并且会变得更加脆弱且难以阅读。
Try to avoid using the techniques I describe in this appendix as much as you can. If you can find a way to write your tests or refactor your code so you don’t need these approaches, use that way. If all else fails, the techniques in this appendix are a necessary evil. If you must use them, try to minimize how much you use them. Your tests will suffer and will become more fragile and harder to read.
猴子修补是指在运行时更改正在运行的程序实例的行为的行为。我第一次遇到这个术语是在 Ruby 工作时,猴子补丁在 Ruby 中非常常见。在 JavaScript 中,在运行时“修补”函数同样容易。
Monkey-patching refers to the act of changing the behavior of a running program instance at run time. I first encountered the term when I was working in Ruby, where monkey-patching is very common. In JavaScript, it’s just as easy to “patch” a function at run time.
在第三章中,我们研究了测试和代码中的时间管理问题。通过猴子修补,我们可以查看任何函数,无论是全局函数还是局部函数,并用不同的实现替换它(对于特定的 JavaScript 范围)。如果我们想要修补时间,我们可以对全局进行猴子修补Date.now,以便从该点开始的任何代码都会受到此更改的影响,包括生产代码和测试代码。
In chapter 3 we looked at the issue of time management in our tests and code. With monkey-patching, we could look at any function, global or local, and replace it (for a specific JavaScript scope) with a different implementation. If we wanted to patch time, we could monkey-patch the global Date.now so that any code from that point on would be affected by this change, both production and test code.
清单 A.1 显示了对直接使用的原始生产代码执行此操作的测试Date.now。它伪造了全局Date.now函数来在测试过程中控制时间。
Listing A.1 shows a test that does this for the original production code that uses Date.now directly. It fakes the global Date.now function to control time during the test.
Listing A.1 Issues in faking the global Date.now()
描述('v1 findRecentlyRebooted', () => {
test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
const OriginalNow = Date.now; ❶
const fromDate = new Date(2000,0,3); ❷
Date.now = () => fromDate.getTime(); ❷
const restartTwoDaysEarly = new Date(2000,0,1);
常量机器 = [
{lastBootTime:rebootTwoDaysEarly,名称:'忽略'},
{lastBootTime: fromDate, name: '找到' }];
const 结果 = findRecentlyRebooted(machines, 1, fromDate);
期望(结果.长度).toBe(1);
Expect(result[0].name).toContain('found');
日期.now = 原始现在; ❸
});
});describe('v1 findRecentlyRebooted', () => {
test('given 1 of 2 machines under threshold, it is found', () => {
const originalNow = Date.now; ❶
const fromDate = new Date(2000,0,3); ❷
Date.now = () => fromDate.getTime(); ❷
const rebootTwoDaysEarly = new Date(2000,0,1);
const machines = [
{ lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
{ lastBootTime: fromDate, name: 'found' }];
const result = findRecentlyRebooted(machines, 1, fromDate);
expect(result.length).toBe(1);
expect(result[0].name).toContain('found');
Date.now = originalNow; ❸
});
});
❶ Saving the original Date.now
❷ Replacing Date.now with a custom date
❸ Restoring the original Date.now
在此列表中,我们将全局日期替换Date.now为自定义日期。因为这是一个全局函数,其他测试可能会受到它的影响,所以我们在测试结束时通过将原始函数恢复Date.now到正确的位置来进行清理。
In this listing, we’re replacing the global Date.now with a custom date. Because this is a global function, other tests can be affected by it, so we clean up after ourselves at the end of the test by restoring the original Date.now to its rightful place.
像这样的测试有几个主要问题。首先,这些断言在失败时会抛出异常,这意味着如果它们失败,原始的恢复Date.now可能永远不会被执行,并且其他测试将遭受可能影响它们的“脏”全局时间。
There are several major issues in a test like this. First, these asserts throw exceptions when they fail, which means if they fail, the restoration of the original Date.now might never be executed, and other tests will suffer a “dirty” global time that might affect them.
保存时间功能再放回去也很麻烦。它在考试中留下了印记,并使其变得更长、更难阅读,更难写。很容易忘记重置全局状态。
It’s also cumbersome to save the time function and then put it back. It’s making its mark on the test and making it longer and harder to read, plus harder to write. It’s easy to forget to reset the global state.
最后,我们削弱了并行性。Jest 似乎很好地处理了这个问题,因为它为每个测试文件创建了一组单独的依赖项,但对于可能并行运行测试的其他框架,可能会出现竞争条件。多次测试可以改变或期望全局时间具有一定的值。并行运行时,这些测试可能会发生冲突并在全局状态中产生竞争条件并相互影响。在我们的例子中这不是必需的,但如果您想消除不确定性,Jest 允许您使用额外的命令行参数运行 Jest 命令行--runInBand以避免并行。
Finally, we’ve impaired parallelism. Jest seems to handle this well, as it creates a separate set of dependencies for each test file, but with other frameworks that might run tests in parallel, there could be a race condition. Multiple tests can change or expect the global time to have a certain value. When running in parallel, these tests can collide and create race conditions in the global state and affect each other. It’s not required in our case, but if you wanted to eliminate uncertainty, Jest allows you to run the Jest command line with the extra --runInBand command-line parameter to avoid parallelism.
我们可以通过使用beforeEach()和afterEach()辅助函数来避免其中一些问题。
We can avoid some of these issues by resorting to the beforeEach() and afterEach() helper functions.
清单 A.2 诉诸beforeEach()和afterEach()
Listing A.2 Resorting to beforeEach() and afterEach()
描述('v2 findRecentlyRebooted',()=> {
让originalNow;
beforeEach(()=>originalNow = Date.now); ❶afterEach
(()=>Date.now=originalNow); ❷
test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
const fromDate = new Date(2000,0,3);
Date.now = () => fromDate.getTime();
const restartTwoDaysEarly = new Date(2000,0,1);
常量机器 = [
{lastBootTime:rebootTwoDaysEarly,名称:'忽略'},
{ lastBootTime: fromDate , name: 'found' }];
const 结果 = findRecentlyRebooted(machines, 1, fromDate);
期望(结果.长度).toBe(1);
Expect(result[0].name).toContain('found');
});
});describe('v2 findRecentlyRebooted', () => {
let originalNow;
beforeEach(() => originalNow = Date.now); ❶
afterEach(() => Date.now = originalNow); ❷
test('given 1 of 2 machines under threshold, it is found', () => {
const fromDate = new Date(2000,0,3);
Date.now = () => fromDate.getTime();
const rebootTwoDaysEarly = new Date(2000,0,1);
const machines = [
{ lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
{ lastBootTime: fromDate, name: 'found' }];
const result = findRecentlyRebooted(machines, 1, fromDate);
expect(result.length).toBe(1);
expect(result[0].name).toContain('found');
});
});
❶ Saving the original Date.now
❷ Restoring the original Date.now
清单 A.2 解决了我们的一些问题,但不是全部。好的部分是我们不再需要记住保存和重置Date.now,因为beforeEach()和afterEach()会处理它。现在阅读测试也更容易了。
Listing A.2 solves some of our issues but not all of them. The good part is that we don’t need to remember to save and reset Date.now anymore, because beforeEach() and afterEach() will take care of it. It’s also now easier to read the tests.
但并行测试仍然存在一个潜在的重大问题。Jest 足够智能,可以仅针对每个文件运行并行测试,这意味着此规范文件中的测试将线性运行,但对于其他文件中的测试,不能保证这种行为。任何一项并行测试都可能有自己的beforeEach(),并且afterEach()会重置全局状态,并且可能会在没有意识到的情况下影响我们的测试。
But we still have a potential major issue with parallel tests. Jest is smart enough to run parallel tests only per file, which means the tests in this spec file will run linearly, but this behavior is not guaranteed for tests in other files. Any one of the parallel tests might have their own beforeEach() and afterEach() that reset global state and might affect our tests without realizing it.
当我可以帮助时,我不喜欢伪造全局对象(即大多数类型语言中的“单例”)。总是有附加条件的——额外的编码、额外的维护、额外的测试脆弱性,或者间接影响其他测试以及一直担心清理都是一些原因。大多数时候,当我将接缝因素纳入被测代码的设计中时,而不是像我们刚才所做的那样以隐式方式围绕它,代码会得到更好的结果。
I’m not a fan of faking global objects (i.e., “singletons” in most typed languages) when I can help it. There are always strings attached—extra coding, extra maintenance, extra test fragility, or affecting other tests indirectly and worrying about cleaning up all the time are some reasons why. Most of the time, the code comes out better when I factor seams into the design of the code under test instead of around it in an implicit manner, such as what we just did.
特别是当考虑到越来越多的框架可能开始复制 Jest 的功能并并行运行测试时,全局伪造变得越来越危险。
Especially when considering that more and more frameworks might start to copy Jest’s features and run tests in parallel, global fakes become more and more dangerous.
为了使图片更加完整,Jest 还通过使用两个协同工作的函数来支持猴子修补的想法:spyOn和mockImplementation。这是spyOn:
To make the picture more complete, Jest also supports the idea of monkey-patching through the use of two functions that work in tandem: spyOn and mockImplementation. Here’s spyOn:
日期.now =开玩笑。间谍(日期,'现在')
Date.now = jest.spyOn(Date, 'now')
spyOn将需要跟踪的范围和功能作为参数。请注意,我们需要在这里使用字符串作为参数,这并不是真正的重构友好 - 如果我们重命名该函数,很容易错过。
spyOn takes as parameters the scope and the function that requires tracking. Note that we need to use a string as a parameter here, which is not really refactoring-friendly—it’s easy to miss if we rename that function.
“间谍”这个词比我们迄今为止在本书中遇到的术语有更有趣的灰色阴影,这就是为什么我不喜欢太多(或根本不)使用它,如果我能帮忙的话它。不幸的是,这个词是 Jest API 的主要部分,所以让我们确保我们理解它。
The word “spy” has a slightly more interesting shade of grey to it than the terms we’ve encountered so far in this book, which is why I don’t like to use it too much (or at all) if I can help it. Unfortunately, this word is a major part of Jest’s API, so let’s make sure we understand it.
Gerard Meszaros 所著的xUnit 测试模式(Addison-Wesley,2007 年)在对间谍的讨论中这样说道:“使用测试替身来捕获被测系统 (SUT) 对另一个组件进行的间接输出调用,以便稍后通过考试。” 间谍与假冒或测试替身之间的唯一区别在于,间谍正在调用下面函数的真实实现,并且它仅跟踪该函数的输入和输出,我们稍后可以通过测试来验证这一点。假冒和测试双打不使用函数的真实实现。
xUnit Test Patterns (Addison-Wesley, 2007), by Gerard Meszaros, says this in its discussion of spies: “Use a Test Double to capture the indirect output calls made to another component by the system under test (SUT) for later verification by the test.” The only difference between a spy and a fake or test double is that a spy is calling the real implementation of the function underneath, and it only tracks the inputs to and outputs from that function, which we can later verify through the test. Fakes and test doubles don’t use the real implementation of a function.
我对间谍的精确定义非常接近:在入口点和出口点上使用不可见的跟踪层包装工作单元的行为,而不更改底层功能,以便在测试期间跟踪其输入和输出。
My refined definition of a spy is pretty close: The act of wrapping a unit of work with an invisible tracking layer on the entry points and exit points without changing the underlying functionality, for the purpose of tracking its inputs and outputs during testing.
这种间谍固有的“跟踪而不改变功能”行为也解释了为什么仅仅使用spyOn不足以让我们伪造Date.now。它只是用于跟踪,而不是伪造。
This “tracking without changing functionality” behavior that is inherent to spies also explains why just using spyOn won’t be enough for us to fake Date.now. It’s only meant for tracking, not faking.
为了真正伪造该Date.now函数并将其转换为桩,我们将使用令人困惑的命名mockImplementation来替换底层工作单元的功能:
To actually fake the Date.now function and turn it into a stub, we’ll use the confusingly named mockImplementation to replace the underlying unit of work’s functionality:
开玩笑。间谍On(日期,“现在”)。mockImplementation (() => /*返回桩时间*/);
jest.spyOn(Date, 'now').mockImplementation(() => /*return stub time*/);
这是spyOn和mockImplementation组合在我们的代码中的样子。
Here’s how the spyOn and mockImplementation combo looks in our code.
清单 A.3 用于jest.SpyOn()猴子补丁Date.now()
Listing A.3 Using jest.SpyOn() to monkey-patch Date.now()
描述('v4 findRecentlyRebooted 开玩笑间谍On',()=> {
afterEach(() => jest.restoreAllMocks() );
test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
const fromDate = new Date(2000,0,3);
Date.now = jest.spyOn(Date, 'now')
.mockImplementation(() => fromDate.getTime());
const restartTwoDaysEarly = new Date(2000,0,1);
常量机器 = [
{lastBootTime:rebootTwoDaysEarly,名称:'忽略'},
{lastBootTime: fromDate, name: '找到' }];describe('v4 findRecentlyRebooted with jest spyOn', () => {
afterEach(() => jest.restoreAllMocks());
test('given 1 of 2 machines under threshold, it is found', () => {
const fromDate = new Date(2000,0,3);
Date.now = jest.spyOn(Date, 'now')
.mockImplementation(() => fromDate.getTime());
const rebootTwoDaysEarly = new Date(2000,0,1);
const machines = [
{ lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
{ lastBootTime: fromDate, name: 'found' }];
可以看到代码中的最后一块拼图就在里面afterEach()。我们使用另一个名为 的函数jest。restoreAllMocks,这是 Jest 将任何已被监视的全局状态重置为其原始实现的方法,周围没有额外的假层。
You can see that the last piece of the puzzle in the code is inside afterEach(). We use another function called jest.restoreAllMocks, which is Jest’s way of resetting any global state that has been spied on to its original implementation with no extra fake layers around it.
请注意,即使我们使用间谍,我们也不会验证该函数是否确实被调用。这样做意味着我们将它用作模拟对象,但我们不是。我们只是将其用作桩。对于 Jest,我们必须通过“间谍”来消除某些东西。
Note that even though we are using a spy, we’re not verifying that the function was actually called. Doing that would mean we’re using it as a mock object, which we are not. We’re merely using it as a stub. With Jest, we have to go through a “spy” to stub stuff out.
我之前列出的所有优点和缺点仍然适用于此。我更喜欢在有意义时使用参数,而不是使用全局函数或变量。
All of the advantages and disadvantages I’ve listed before still apply here. I prefer using parameters when it makes sense, instead of using global functions or variables.
在本附录中提到的所有技术中,这是最安全的,因为它不涉及被测单元的内部工作。它只是广泛地忽略了事情。
Of all the techniques mentioned in this appendix, this is the safest because it does not deal with the internal workings of the unit under test. It just ignores things in a broad manner.
如果我们在测试期间根本不关心该模块,并且我们只想让它脱离我们的场景而不从中获取任何虚假数据,那么对jest.mock('module path')测试文件顶部的简单调用将做得很好,没有太多大惊小怪。
If we don’t care about the module at all during our tests, and we just want to get it out of the way of our scenario without getting any fake data back from it, a simple call to jest.mock('module path') at the top of the test file will do just fine, without too much fuss.
如果您想在每个测试中从假模块模拟自定义数据,那么下一节会有所帮助,这会让我们经历更多的麻烦。
The next section helps if you want to simulate custom data in each test from a fake module, which makes us go through more hoops.
伪造模块基本上意味着伪造全局对象import每当或require第一次被测试代码使用时都会加载它。根据我们使用的测试框架,模块可能会在内部缓存或通过标准 Node.jsrequire.cache机制缓存。由于这只发生一次,当我们的测试导入被测系统时,当我们试图在同一文件中为不同的测试伪造不同的行为或数据时,我们会遇到一些问题。
Faking a module basically means faking a global object that gets loaded whenever import or require is used for the first time by the code under test. Depending on the test framework we’re using, the module might be cached internally or through the standard Node.js require.cache mechanism. Since this only happens once, when our test imports the system under test, we have a bit of an issue when we’re trying to fake different behavior or data for different tests in the same file.
为了伪造我们的假模块的自定义行为,我们需要在测试中注意以下事项:从内存中清理所需的模块,替换它,重新需要它,并让被测试的代码使用新模块而不是使用新模块。通过要求我们再次测试我们的代码来恢复原来的状态。那是相当多了。我将此模式称为 Clear-Fake-Require-Act (CFRA):
To fake custom behavior for our fake module, we need to take care of the following in our tests: clean up the required module from memory, replace it, re-require it, and get the code under test to use the new module instead of the original one by requiring our code under test again. That’s quite a bit. I call this pattern Clear-Fake-Require-Act (CFRA):
如果我们忘记了这些步骤中的任何一个,或者以错误的顺序执行它们,或者不在测试生命周期的正确时间点执行它们,那么当我们执行测试时就会出现很多问号,并且事情似乎不正确。 。更糟糕的是,它们有时可能会正常工作。不寒而栗。
If we forget any of these steps, or perform them in the wrong order, or not at the right point in the test’s life cycle, there’ll be a lot of question marks when we execute the test and things seem not to be faking correctly. Worse, they might sometimes work correctly. Shudder.
Let’s look at a real example, starting with the following code.
Listing A.4 Code under test with a dependency
const { getAllMachines } = require('./my-data-module'); ❶
const daysFrom = (从, 到) => {
const ms = from.getTime() - new Date(to).getTime();
常量差异 = (毫秒 / 1000) / 60 / 60 / 24; // 秒 * 分钟 * 小时
控制台.log(差异);
返回差异;
};
const findRecentlyRebooted = (maxDays, fromDate) => {
const 机器 = getAllMachines();
返回machines.filter(机器=> {
const daysDiff = daysFrom(fromDate, machine.lastBootTime);
console.log(`${daysDiff} vs ${maxDays}`);
返回 daysDiff < maxDays;
});
};const { getAllMachines } = require('./my-data-module'); ❶
const daysFrom = (from, to) => {
const ms = from.getTime() - new Date(to).getTime();
const diff = (ms / 1000) / 60 / 60 / 24; // secs * min * hrs
console.log(diff);
return diff;
};
const findRecentlyRebooted = (maxDays, fromDate) => {
const machines = getAllMachines();
return machines.filter(machine => {
const daysDiff = daysFrom(fromDate, machine.lastBootTime);
console.log(`${daysDiff} vs ${maxDays}`);
return daysDiff < maxDays;
});
};
第一行包含我们需要在测试中打破的依赖关系。它是getAllMachines从 解构出来的函数my-data-module。因为我们使用的是与其父模块分离的函数,所以我们不能只在父模块上伪造函数并期望我们的测试能够通过。我们必须获得解构函数才能在解构过程中获得假函数,这就是棘手的部分。
The first line contains the dependency we need to break in our test. It’s the getAllMachines function, being destructured from my-data-module. Because we are using the function detached from its parent module, we can’t just fake functions on the parent module and expect our tests to pass. We have to get the destructured function to get a fake function during the destructuring process, and that’s where the tricky part comes in.
在我们使用 Jest 和其他框架来伪造整个模块之前,让我们看看如何实现这种效果并探索各个框架中发生的情况。
Before we use Jest and other frameworks to fake a whole module, let’s see how we can achieve this effect and explore what’s going on in the various frameworks.
您可以直接使用 CFRA 模式,而无需使用任何框架require.cache。
You can use the CFRA pattern without using any framework by using require.cache directly.
Listing A.5 Stubbing with require.cache
const 断言 = require('断言');
const { 检查 } = require('./custom-test-framework');
const dataModulePath = require.resolve('../my-data-module');
const fakeDataFromModule = fakeData => {
删除 require.cache[dataModulePath]; ❶
require.cache[dataModulePath] = { ❷
id:数据模块路径,
文件名:数据模块路径,
已加载:真实,
出口:{
getAllMachines: () => fakeData
}
};
需要(数据模块路径);
};
const requireAndCall _ findRecentlyRebooted = (maxDays, fromDate) => {
const { findRecentlyRebooted } = require('../machine-scanner4'); ❸
return findRecentlyRebooted(maxDays, fromDate); ❹
};
check('给定 2 台机器中的 1 台低于阈值,找到它', () => {
const restartTwoDaysEarly = new Date(2000,0,1);
const fromDate = new Date(2000,0,3);
fakeDataFromModule([
{lastBootTime:rebootTwoDaysEarly,name:'ignored'},
{lastBootTime:fromDate,name:'found'}
]);
const 结果 = requireAndCall _ findRecentlyRebooted(1, fromDate) ;
断言(结果.length === 1);
断言(结果[0].name.includes('found'));
});const assert = require('assert');
const { check } = require('./custom-test-framework');
const dataModulePath = require.resolve('../my-data-module');
const fakeDataFromModule = fakeData => {
delete require.cache[dataModulePath]; ❶
require.cache[dataModulePath] = { ❷
id: dataModulePath,
filename: dataModulePath,
loaded: true,
exports: {
getAllMachines: () => fakeData
}
};
require(dataModulePath);
};
const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
const { findRecentlyRebooted } = require('../machine-scanner4'); ❸
return findRecentlyRebooted(maxDays, fromDate); ❹
};
check('given 1 of 2 machines under the threshold, it is found', () => {
const rebootTwoDaysEarly = new Date(2000,0,1);
const fromDate = new Date(2000,0,3);
fakeDataFromModule([
{ lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
{ lastBootTime: fromDate, name: 'found' }
]);
const result = requireAndCall_findRecentlyRebooted(1, fromDate);
assert(result.length === 1);
assert(result[0].name.includes('found'));
});
不幸的是,这段代码不适用于 Jest,因为 Jest 忽略require.cache并在内部实现了自己的缓存算法。要执行此测试,请直接通过 Node.js 命令行运行它。您会看到我已经实现了自己的小check()功能,因此我不使用 Jest 的 API。当使用 Jasmine 等框架时,此测试可以正常工作。
Unfortunately, this code will not work with Jest, because Jest ignores require.cache and implements its own caching algorithm internally. To execute this test, run it directly through the Node.js command line. You’ll see that I’ve implemented my own little check() function, so that I don’t use Jest’s API. This test will work just fine when using a framework such as Jasmine.
Remember this line in our code under test?
const { getAllMachines } = require('./my-data-module');const { getAllMachines } = require('./my-data-module');
每次我们想要返回一个假值时,我们的测试都需要执行这种解构。这意味着我们需要从测试代码执行被测单元的 require 或导入,不是在文件顶部,而是在测试执行中间的某个位置。您可以在清单 A.5 的以下部分中看到这种情况发生的位置:
Our tests need to execute this destructuring every time we want to return a fake value. That means we’ll need to execute a require or import of the unit under test from our test code, not at the top of the file, but somewhere in the middle of our test execution. You can see where this happens in the following part of listing A.5:
const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
const { findRecentlyRebooted } = require('../machine-scanner4');
返回 findRecentlyRebooted(maxDays, fromDate);
};const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
const { findRecentlyRebooted } = require('../machine-scanner4');
return findRecentlyRebooted(maxDays, fromDate);
};
正是由于这种解构代码模式,模块不仅仅是具有属性的对象,可以使用普通的猴子修补技术。我们需要跨越更多的障碍。
It is because of this destructuring code pattern that modules are not just objects with properties, for which normal monkey-patching techniques can be used. We need to jump through more hoops.
Let’s map the four CFRA steps to the code in listing A.5:
Clear—This is part of the fakeDataFromModule function, which is invoked during the test.
Fake——我们告诉require.cache的字典条目返回一个自定义对象,该对象似乎代表了一个模块的样子,但它有一个返回 的自定义实现fakeData。
Fake—we are telling require.cache’s dictionary entry to return a custom object that seems to represent what a module looks like, but which has a custom implementation that returns fakeData.
Require——我们需要被测试的代码作为requireAndCall_ findRecentlyRebooted()函数的一部分,该函数在测试期间被调用。
Require—We are requiring the code under test as part of the requireAndCall_ findRecentlyRebooted() function, which is invoked during the test.
ACT—This is part of the same requireAndCall_findRecentlyRebooted() function that is invoked by the test.
请注意,我们不用beforeEach()于此测试。我们直接从测试中进行所有操作,因为每个测试都会从模块中伪造自己的数据。
Notice that we do not use beforeEach() for this test. We are doing everything directly from the test, because each test will fake its own data from the module.
我们已经看到了桩自定义模块数据的“普通”方式。不过,如果您使用 Jest,通常不会这样做。Jest 包含几个令人困惑且命名非常接近的函数,用于处理清除和伪造模块,包括mock、doMock、genMockFromModule、resetAllMocks、clearAllMocks、restoreAllMocks等resetModules。耶!
We’ve seen the “vanilla” way of stubbing custom module data. That’s not usually how you’d do it if you’re using Jest, though. Jest contains several confusingly and very closely named functions that deal with clearing and faking modules, including mock, doMock, genMockFromModule, resetAllMocks, clearAllMocks, restoreAllMocks, resetModules and more. Yay!
我在这里推荐的代码在可读性和可维护性方面感觉是所有 Jest API 中最干净、最简单的。我确实在 GitHub 存储库(https://github.com/royosherove/aout3-samples)和“other-variations”文件夹(http://mng.bz/Jddo )中介绍了它的其他变体。
The code I’ll recommend here feels the cleanest and simplest of all of Jest’s APIs in terms of readability and maintainability. I do cover other variations on it in the GitHub repository at https://github.com/royosherove/aout3-samples and under the “other-variations” folder at http://mng.bz/Jddo.
This is the common pattern for faking a module with Jest:
[modulename].function.mockImplementation()在每个测试中,告诉 Jest 使用或覆盖该模块中函数之一的行为mockImplementationOnce()。
In each test, tell Jest to override the behavior of one of the functions in that module by using [modulename].function.mockImplementation() or mockImplementationOnce().
The following is what it might look like.
Listing A.6 Stubbing a module with Jest
const dataModule = require('../my-data-module');
const { findRecentlyRebooted } = require('../machine-scanner4');
const fakeDataFromModule = (fakeData) =>
dataModule.getAllMachines.mockImplementation(() => fakeData);
jest.mock('../my-data-module');
描述('findRecentlyRebooted', () => {
beforeEach(jest.resetAllMocks); //<- 最干净的方式
test('没有机器,返回空结果', () => {
fakeDataFromModule([]);
const someDate = new Date(2000,0,1);
const 结果 = findRecentlyRebooted(0, someDate);
期望(结果.长度).toBe(0);
});
test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
const fromDate = new Date(2000,0,3);
const restartTwoDaysEarly = new Date(2000,0,1);
fakeDataFromModule([
{lastBootTime:rebootTwoDaysEarly,name:'ignored'},
{lastBootTime:fromDate,name:'found'}
]);
const 结果 = findRecentlyRebooted(1, fromDate);
期望(结果.长度).toBe(1);
Expect(result[0].name).toContain('found');
});const dataModule = require('../my-data-module');
const { findRecentlyRebooted } = require('../machine-scanner4');
const fakeDataFromModule = (fakeData) =>
dataModule.getAllMachines.mockImplementation(() => fakeData);
jest.mock('../my-data-module');
describe('findRecentlyRebooted', () => {
beforeEach(jest.resetAllMocks); //<- the cleanest way
test('given no machines, returns empty results', () => {
fakeDataFromModule([]);
const someDate = new Date(2000,0,1);
const result = findRecentlyRebooted(0, someDate);
expect(result.length).toBe(0);
});
test('given 1 of 2 machines under threshold, it is found', () => {
const fromDate = new Date(2000,0,3);
const rebootTwoDaysEarly = new Date(2000,0,1);
fakeDataFromModule([
{ lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
{ lastBootTime: fromDate, name: 'found' }
]);
const result = findRecentlyRebooted(1, fromDate);
expect(result.length).toBe(1);
expect(result[0].name).toContain('found');
});
Here’s how you can approach each part of CFRA with Jest.
和jest.mock方法jest.resetAllMocks都是关于伪造模块并将伪造的实现重置为空的。请注意,该模块在 . 之后仍然是假的resetAllMocks。仅其行为被重置为默认的假实现。调用它而不告诉它返回什么会产生奇怪的错误。
The jest.mock and jest.resetAllMocks methods are all about faking the module and resetting the fake implementation to an empty one. Note that the module is still fake after resetAllMocks. Only its behavior is reset to the default fake implementation. Calling it without telling it what to return will yield weird errors.
使用该FromModule方法,我们用一个在每个测试中返回硬编码值的函数替换默认实现。
With the FromModule method, we replace the default implementation with a function that returns our hardcoded values in each test.
我们本来可以使用mockImplementationOnce()模拟来代替fakeDataFromModule()方法,但我发现这会创建非常脆弱的测试。对于桩,我们通常不应该关心它们返回假值的次数。如果我们确实关心它们被调用了多少次,我们会将它们用作模拟对象,这就是第 4 章的主题。
We could have used mockImplementationOnce() to do mocking, instead of the fakeDataFromModule() method, but I find that this can create very brittle tests. With stubs, we normally shouldn’t care how many times they return the fake values. If we did care how many times they were called, we would use them as mock objects, and that’s the subject of chapter 4.
Jest 包含手动模拟的想法,但如果可以的话就不要使用它们。此技术要求您在测试中放置一个特殊的 __mocks__ 文件夹,其中包含硬编码的假模块代码,并具有基于模块名称的命名约定。这可行,但是当你想控制假数据时,可维护性成本太高。可读性成本也太高,因为它将滚动疲劳增加到不必要的水平,需要我们在多个文件之间切换才能理解测试。您可以在 Jest 文档中阅读有关手动模拟的更多信息:https://jestjs.io/docs/en/manual-mocks.xhtml。
Jest contains the idea of manual mocks, but don’t use them if you can help it. This technique requires you to put a special __mocks__ folder in your tests that contain hardcoded fake module code, with a naming convention based on the module’s name. This will work, but the maintainability costs are too high when you want to control the fake data. The readability costs are too high as well, as it increases scroll fatigue to an unneeded level, requiring us to switch between multiple files to understand a test. You can read more about manual mocks in the Jest documentation: https://jestjs.io/docs/en/manual-mocks.xhtml.
为了进行比较,您可以看到 CFRA 的模式在其他框架中重复,下面是使用 Sinon.js(一个专门用于创建桩的框架)进行相同测试的实现。
For comparison, and so that you can see that the pattern of CFRA repeats in other frameworks, here’s an implementation of the same test with Sinon.js—a framework dedicated to creating stubs.
Listing A.7 Stubbing a module with Sinon.js
const sinon = require('sinon');
让数据模块;
const fakeDataFromModule = fakeData => {
sinon.stub(dataModule, 'getAllMachines')
.returns(fakeData);
};
const resetAndRequireModules = () => {
jest.resetModules();
dataModule = require('../my-data-module');
};
const requireAndCall_findRecentlyRebooted = (maxDays, someDate) => {
const { findRecentlyRecentlyRebooted } = require('../machine-scanner4');
返回 findRecentlyRebooted(maxDays, someDate);
};
描述('4 sinon沙箱findRecentlyRebooted', () => {
beforeEach(resetAndRequireModules);
test('没有机器,返回空结果', () => {
const someDate = new Date('01 01 2000');
fakeDataFromModule([]);
const result = requireAndCall_findRecentlyRebooted (2, someDate);
期望(结果.长度).toBe(0);
});const sinon = require('sinon');
let dataModule;
const fakeDataFromModule = fakeData => {
sinon.stub(dataModule, 'getAllMachines')
.returns(fakeData);
};
const resetAndRequireModules = () => {
jest.resetModules();
dataModule = require('../my-data-module');
};
const requireAndCall_findRecentlyRebooted = (maxDays, someDate) => {
const { findRecentlyRebooted } = require('../machine-scanner4');
return findRecentlyRebooted(maxDays, someDate);
};
describe('4 sinon sandbox findRecentlyRebooted', () => {
beforeEach(resetAndRequireModules);
test('given no machines, returns empty results', () => {
const someDate = new Date('01 01 2000');
fakeDataFromModule([]);
const result = requireAndCall_findRecentlyRebooted(2, someDate);
expect(result.length).toBe(0);
});
Let’s map the relevant parts with Sinon.
测试替身是另一个隔离可以很容易地用来解决问题的框架。由于在之前的测试中已经完成了重构,因此代码更改很小。
Testdouble is another isolation framework that can easily be used to stub things out. Due to the refactoring already done in previous tests, the code changes are minimal.
Listing A.8 Stubbing a module with testdouble
让td;
const ResetAndRequireModules = () => {
jest.resetModules();
td = require('testdouble');
require('testdouble-jest')(td, 玩笑);
};
const fakeDataFromModule = fakeData => {
td.replace('../my-data-module', {
getAllMachines: () => fakeData
});
};
const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
const { findRecentlyRebooted } = require('../machine-scanner4');
返回 findRecentlyRebooted(maxDays, fromDate);
};let td;
const resetAndRequireModules = () => {
jest.resetModules();
td = require('testdouble');
require('testdouble-jest')(td, jest);
};
const fakeDataFromModule = fakeData => {
td.replace('../my-data-module', {
getAllMachines: () => fakeData
});
};
const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
const { findRecentlyRebooted } = require('../machine-scanner4');
return findRecentlyRebooted(maxDays, fromDate);
};
Here are the important parts with testdouble.
|
|
|
测试实现与Sinon示例完全相同。我们还使用testdouble-jest,因为它连接到 Jest 模块替换设施。如果我们使用不同的测试框架,则不需要这样做。
The test implementation is exactly the same as with the Sinon example. We’re also using testdouble-jest, as it connects to the Jest module replacement facility. This is not needed if we’re using a different test framework.
这些技术会起作用,但我建议远离它们,除非绝对没有其他办法。几乎总是有其他方法,您可以在第 3 章中看到其中的许多方法。
These techniques will work, but I recommend staying away from them unless there’s absolutely no other way. There is almost always another way, and you can see many of those in chapter 3.
AAA (Arrange-Act-Assert) pattern 38
advantages and traps of isolation frameworks 117 – 120
afterEach() 函数 45 , 173 , 240
afterEach() function 45, 173, 240
low-level-only test antipattern 202
test-level, end-to-end-only antipattern 199
API (application programming interface) tests 198
Array.prototype.every() 方法 40 – 41
Array.prototype.every() method 40 – 41
verifyPassword() function 40 – 41
async/await function structures 129
asynchronous code, unit testing 121 – 145
asynchronous data fetching 122
dealing with event emitters 141 – 142
object-oriented-interface-based adapter 136 – 138
Extract Entry Point pattern 125
使代码单元测试友好 125 , 127 – 129
making code unit-test friendly 125, 127 – 129
stubbing out with monkey-patching 138 – 139
unit-test-friendly code 125 – 138
Extract Adapter pattern 131 – 133
challenges with integration tests 125
另请参阅单元测试友好的代码
See also unit-test-friendly code
asynchronous processing, emulating with linear, synchronous tests 16
avoiding setup methods 175 – 176
BDD (behavior-driven development) 42 – 43
beforeEach() 函数 45 – 47 , 50 – 51 , 240
beforeEach() function 45 – 47, 50 – 51, 240
buggy tests, what to do once you’ve found 151
bugs, real bug in production code 151
breaking up into groups 221 – 222
calculator example, factory methods 49 – 50
CFRA (Clear-Fake-Require-Act) pattern 243
considering project feasibility 216
identifying starting points 215
making progress visible 219 – 220
colleagues’ attitudes, being prepared for tough questions 214
in functionality, avoiding or preventing test failure due to 152
testing culture and using code and test reviews as teaching tools 216
code reviews, using as teaching tools 216
CodeScene, investigating production code with 236
command/query separation 9, 106
common test types and levels, E2E/UI system tests 199
复杂的接口,例如98 – 99
complicated interfaces, example of 98 – 99
component tests, overview of 196
concerns, testing multiple exit points 158 – 160
constrained test order 170 – 173
CUT (component, class, or code under test) 6
database, replacing with stubs 16
debuggers, need for tests if code works 229
decoupling, factory functions decouple creation of object under test 168 – 169
delivery vs. discovery pipelines 208
test layer parallelization 210 – 211
breaking with stubs, object-oriented injection techniques 74
design approaches to stubbing 66
functional injection techniques 69 – 70
模块化注射技术 70 – 73
modular injection techniques 70 – 73
object-oriented injection techniques 79 – 81
Dependency Injection (DI) containers 77
描述 () 函数 30 , 40 – 41 , 52
describe() function 30, 40 – 41, 52
differentiating between mocks and stubs 88 – 89
diminishing returns from E2E (end-to-end) tests 199
direct dependencies, abstracting away 109
discovery pipelines, vs. delivery pipelines 208
DOM (Document Object Model) testing library 144 – 145
done() 函数 124 , 129 , 142
DRY (don’t repeat yourself) principle 175
动态模拟和桩 104 , 109 – 110
dynamic mocks and stubs 104, 109 – 110
E2E (end-to-end) tests 198 – 199
end-to-end-only antipattern 199 – 201
avoiding E2E tests completely 202
throw it over the wall mentality 201
entry points 6 – 10, 241
extracting, with await 129 – 131
event-driven programming 142 – 144
exceptions, checking for expected thrown errors 55 – 56
出口点 6 , 9 – 11 , 158 – 160 , 241
exit points 6, 9 – 11, 158 – 160, 241
different exit points, different techniques 12
expect().toThrowError() method 55
Extract Adapter pattern 131 – 133
object-oriented-interface-based adapter 136 – 138
extracting adapter pattern 125
extracting entry point pattern 125
replacing beforeEach() completely with 50 – 51
FakeComplicatedLogger class 100
fakeDataFromModule() 方法 245 , 247
fakeDataFromModule() method 245, 247
FakeLogger 类 96 , 98 , 168
avoiding Jest’s manual mocks 247
stubbing modules with Sinon.js 247
fake modules, dynamically 106 – 108
faking module behavior in each test 248 – 249
stubbing with testdouble 248 – 249
fake (xUnit Test Patterns, Meszaros) 64
findFailedRules() 函数 178 – 179
findFailedRules() function 178 – 179
first unit test, setting test categories 56 – 57
mixing unit tests and integration tests 158
preventing flakiness in higher-level tests 163
folders, preparing for Jest 29 – 30
functional dynamic mocks and stubs 109 – 110
functional injection techniques 69
functionality, change in, out of date tests 152
injecting instead of objects 76 – 79
每次测试中伪造模块行为 243 – 244 , 247 – 249
faking module behavior in each test 243 – 244, 247 – 249
globals and possible issues 239 – 241
ignoring whole modules with Jest 242
genMockFromModule() function 246
monkey-patching functions and modules 239 – 241
以测试为指导,不断发展面向对象的软件(Freeman 和 Pryce)26
Growing Object-Oriented Software, Guided by Tests (Freeman and Pryce) 26
hard-first strategy, pros and cons of 234
high-level tests, disconnected low-level and 204
IComplicatedLogger interface 99
ILogger接口 96 , 98 , 166
breaking up into groups 221 – 222
injectDependencies() function 91
modular-style injection, example of 92
into organization, convincing management 217
unit testing, integrating into organization 228
flaky, mixing with unit tests 158
complicated interfaces 98 – 101
differentiating between mocks and stubs 88 – 89
in object-oriented style 94, 96
interfaces, complicated, ISP 101
interface segregation principle 131, 133
internal behavior, overspecification with mocks 177 – 179
Inversion of Control (IoC) containers 77
isolation frameworks 104 – 120
faking modules dynamically 106 – 108
abstracting away direct dependencies 109
functional dynamic mocks and stubs 109 – 110
object-oriented dynamic mocks and stubs 110
using loosely typed framework 110 – 112
stubbing behavior dynamically 114 – 117
object-oriented example with mock and stub 114 – 116
type-friendly frameworks 112 – 113
ISP (interface segregation principle) 101
isWebsiteAlive() function 129, 133
it() 函数 30 , 42 , 51
ignoring whole modules with 242
library, assert, runner, and reporter 33
准备工作文件夹 29 – 30
preparing working folder 29 – 30
jest.mock API, abstracting away direct dependencies 109
jest.mock([module name]) function 108
jest.restoreAllMocks function 242
Jest unit testing framework 36
KPIs (key performance indicators) 208, 220
breaking up into groups 221 – 222
integration tests, writing before refactoring 236
hard-first strategy, pros and cons of 234
where to start adding tests 232 – 233
writing integration tests before refactoring 235
using CodeScene to investigate production code 236
loadHtmlAndGetUIElements method 144
记录器,取决于 85 – 86
loose isolation frameworks 105
loosely typed frameworks 110 – 112
low-level-only test antipattern 202
low-level tests, disconnected high-level and 204
LTS (long-term support) release 29
avoiding overspecification 177 – 179
changes forced by failing tests
constrained test order 170 – 173
of code, exact outputs and ordering overspecification 179 – 183
changes forced by failing tests 166
changes in production code’s API 166 – 168
test is not relevant or conflicts with another test 166
avoiding setup methods 175 – 176
avoiding testing private or protected methods 175
using parameterized tests to remove duplication 176 – 177
MaintenanceWindow interface 115 – 116
makeSpecialApp() factory function 173
makeStubNetworkWithResult() 辅助函数 136
makeStubNetworkWithResult() helper function 136
杰拉德·梅萨罗斯 11 , 64 , 89
mockImplementation() function 241 – 242
mockImplementation() method 114
mockImplementationOnce() 方法 114 , 247
mockImplementationOnce() method 114, 247
mock objects 12, 83, 98 – 103, 247
advantages of isolation frameworks 118
downsides of using directly 100
differentiating between mocks and stubs 88 – 89
interaction testing with complicated interfaces 99 – 100
refactoring production code in modular injection style 91
object-oriented partial mock example 102 – 103
standard style, introducing parameter refactoring 87 – 88
mockReturnValueOnce() method 114
advantages of, having more than one mock per test 119
higher-order functions and not currying 93
refactoring production code for injection 95
refactoring production code with interface injection 96
internal behavior overspecification with 177 – 179
object-oriented design example with 114 – 116
object-oriented dynamic mocks and stubs 110
模块化注射技术 70 – 73
modular injection techniques 70 – 73
example of production code 90 – 91
modular-style injection, example of 92
refactoring production code in modular injection style 91
faking behavior in each test 243 – 249
stubbing with testdouble 248 – 249
abstracting away direct dependencies 109
faking module behavior in each test 248 – 249
带有普通 require.cache 的桩模块 244 – 245
stubbing module with vanilla require.cache 244 – 245
stubbing with testdouble 248 – 249
使用 mockImplementation() 进行间谍活动 241 – 242
spyOn with mockImplementation() 241 – 242
network-adapter module 132 – 134
NPM (node package manager) 4, 29
object-oriented design, example with mock and stub 114 – 116
object-oriented dynamic mocks and stubs 110
type-friendly frameworks 112 – 113
using loosely typed framework 110 – 112
object-oriented injection techniques 74 – 81
extracting common interface 79 – 81
injecting objects instead of functions 76 – 79
object-oriented style, mocks in 94
organization, integrating unut testing into 229
originalDependencies object 71
originalDependencies variable 91
exact outputs and ordering 179 – 183
removing duplication with 176 – 177
partial application, dependency injection via 70
object-oriented partial mock example 102 – 103
password-verifier0.spec.js file 37
passwordVerifierFactory() function 76
patching functions and modules
avoiding Jest’s manual mocks 247
faking module behavior in each test 243 – 244
ignoring whole modules with Jest 242
ports and adapters architecture 109
preconfigured verifier function 94
private and protected methods, avoiding testing 175
modular-style mocks, example of 90 – 91
refactoring in modular injection style 91
refactoring with interface injection 96
production code, investigating with CodeScene 236
生产代码,重构 43 – 45
production code, refactoring 43 – 45
progress, making visible 219 – 220
magic values and naming variables 189 – 190
separating asserts from actions 190
avoiding testing private or protected methods 175
production code for injection 95
avoiding setup methods 175 – 176
avoiding testing private or protected methods 174
to parameterized tests 53 – 55
using parameterized tests to remove duplication 176 – 177
writing integration tests before 235
requireAndCall_findRecentlyRebooted() 函数 245
requireAndCall_findRecentlyRebooted() function 245
require.cache,带有普通版本的桩模块 244 – 245
require.cache, stubbing module with vanilla 244 – 245
resetDependencies() function 91 – 92
restoreAllMocks() function 246
rules verification functions 37
abstracting dependencies using 86
Sinon.js, stubbing modules with 247
SpecialApp implementation 170 – 171
stateless private methods, making public static 175
dependencies, injections, and control 68
stubbing out time with parameter injection 66 – 67
functional injection techniques 69
模块化注射技术 70 – 73
modular injection techniques 70 – 73
object-oriented injection techniques 74 – 81
extracting common interface 79 – 81
injecting objects instead of functions 76 – 79
replacing database (or another dependency) with 16
subject, system, or suite under test (SUT) 6
Substitute.for<T>() function 116
SUT (subject, system, or suite under test) 6
TAP (Test Anything Protocol) 36
TDD(测试驱动开发)5 个带参数注入的stubbing out 时间, 22 个带参数注入的stubbing out 时间, 152 – 229
TDD (test-driven development) 5stubbing out time with parameter injection, 22stubbing out time with parameter injection, 152 – 229
pitfalls of, not a substitute for good unit tests 24
test() function, overview of 52
test conflicts with another test 152 – 153
stubbing modules with 248 – 249
测试驱动开发 (TDD) 5 , 25 , 152
test-driven development (TDD) 5, 25, 152
test conflicts with another test 152 – 153
test out of date due to change in functionality 152
buggy test gives false failure 151
test conflicts with another test 152
smelling false sense of trust in passing tests 156
common test types and levels 195
criteria for judging tests 196
unit tests and component tests 196
developing, using test recipes 205
low-level-only test antipattern 202
managing delivery pipelines 208
delivery vs. discovery pipelines 208
test layer parallelization 210 – 211
disconnected low-level and high-level tests 203
end-to-end-only antipattern 199, 201
test layer parallelization 210 – 211
test maintainability, constrained test order 170 – 173
testPathPattern command-line flag 56
test reviews, using as teaching tools 216
tests, criteria for judging 196
time, added to process 226 – 227
stubbing out with monkey-patching 138 – 139
.toContain('fake reason') function 38
toMatchInlineSnapshot() method 56
.toMatch(/string/) function 38
值得信赖的测试 36 , 149 – 164
trustworthy tests 36, 149 – 164
避免单元测试中的逻辑 153 , 155 – 156
avoiding logic in unit tests 153, 155 – 156
creating dynamic expected values 153 – 155
criteria for judging tests 196
what to do once you’ve found 151
reasons for test failure 150 – 152
buggy test gives false failure 151
out of date due to change in functionality 152
real bug in production code 151
test conflicts with another test 152
smelling false sense of trust in passing tests 156
mixing unit tests and flaky integration tests 158
testing multiple exit points 158 – 160
tests that don’t assert anything 157
test conflicts with another test 153
type-friendly frameworks 112 – 113
UI (user interface) tests 198 – 199
characteristics of good unit tests 15 – 16
creating unit tests from scratch 12 – 15
different exit points, different techniques 12
educating colleagues about, being prepared for tough questions 214
entry points and exit points 6 – 10
第一个单元测试 28 – 58
frameworks, advantages of 34 – 36
integrating into organization 213 – 230
ad hoc implementations and first impressions 223
aiming for specific goals, metrics, and KPIs 220
experiments as door openers 217
need for tests if debugger shows that code works 229
steps to becoming agent of change 214 – 216
TDD(测试驱动开发)25 , 226 – 230
TDD (test-driven development) 25, 226 – 230
time added to process 226 – 227
tough questions and answers 226 – 229
proof that unit testing helps 228
realize that there will be hurdles 222
interaction testing using mock objects 83 – 103
where to start adding tests 232 – 233
writing integration tests before refactoring 235 – 236
unit testing asynchronous code, example of extracting unit of work 127 – 129
Unit Testing Principles, Practices, and Patterns (Khorikov) 236
creating dynamic expected values 153 – 155
other forms of logic 155 – 156
avoiding overspecification 177
emulating asynchronous processing with linear, synchronous tests 16
replacing database (or another dependency) with stub 16
characteristics of good unit tests 15
exceptions, checking for expected thrown errors 55 – 56
faking module behavior in each test 244 – 245
第一个单元测试 28 – 58
refactoring to beforeEach() function 47 – 49
用工厂方法完全替换 beforeEach() 50 – 51
replacing beforeEach() completely with factory methods 50 – 51
first test with, preparing working folder 29 – 30
library, assert, runner, and reporter 33
parameterized tests, refactoring to 53 – 55
magic values and naming variables 189 – 190
separating asserts from actions 190
setting up and tearing down 191 – 192
refactoring production code 43 – 45
setting test categories 56 – 57
USE (unit, scenario, expectation) naming 39
vanilla require.cache 244 – 245
verify() 函数 55 良好单元测试的特征 15, 95 良好单元测试的特征 15, 178 – 180
verify() function 55characteristics of good unit tests 15, 95characteristics of good unit tests 15, 178 – 180
verifyPassword() 函数 45 良好单元测试的特征 15, 86 良好单元测试的特征 15, 90
verifyPassword() function 45characteristics of good unit tests 15, 86characteristics of good unit tests 15, 90
refactoring production code 43 – 45
structure can imply context 41 – 42
verifyPassword(rules) function 37
WebsiteVerifier class 136 – 137
website-verifier example 135 – 136
xUnit test patterns and naming things 64
xUnit 测试模式:重构测试代码(Meszaros) 11 , 64 , 89
xUnit Test Patterns: Refactoring Test Code (Meszaros) 11, 64, 89